Popover
A headless popover animation component with customizable options.
References
Inspiration
Installation
Copy and paste the following code into your project.
"use client"
import React, {
createContext,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react"
import { AnimatePresence, MotionConfig, motion } from "framer-motion"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const TRANSITION = {
type: "spring",
bounce: 0.05,
duration: 0.3,
}
function useClickOutside(
ref: React.RefObject<HTMLElement>,
handler: () => void
) {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler()
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [ref, handler])
}
interface PopoverContextType {
isOpen: boolean
openPopover: () => void
closePopover: () => void
uniqueId: string
note: string
setNote: (note: string) => void
}
const PopoverContext = createContext<PopoverContextType | undefined>(undefined)
function usePopover() {
const context = useContext(PopoverContext)
if (!context) {
throw new Error("usePopover must be used within a PopoverProvider")
}
return context
}
function usePopoverLogic() {
const uniqueId = useId()
const [isOpen, setIsOpen] = useState(false)
const [note, setNote] = useState("")
const openPopover = () => setIsOpen(true)
const closePopover = () => {
setIsOpen(false)
setNote("")
}
return { isOpen, openPopover, closePopover, uniqueId, note, setNote }
}
interface PopoverRootProps {
children: React.ReactNode
className?: string
}
export function PopoverRoot({ children, className }: PopoverRootProps) {
const popoverLogic = usePopoverLogic()
return (
<PopoverContext.Provider value={popoverLogic}>
<MotionConfig transition={TRANSITION}>
<div
className={cn(
"relative flex items-center justify-center isolate",
className
)}
>
{children}
</div>
</MotionConfig>
</PopoverContext.Provider>
)
}
interface PopoverTriggerProps {
children: React.ReactNode
className?: string
}
export function PopoverTrigger({ children, className }: PopoverTriggerProps) {
const { openPopover, uniqueId } = usePopover()
return (
<motion.button
key="button"
layoutId={`popover-${uniqueId}`}
className={cn(
"flex h-9 items-center border border-zinc-950/10 bg-white px-3 text-zinc-950 dark:border-zinc-50/10 dark:bg-zinc-700 dark:text-zinc-50",
className
)}
style={{
borderRadius: 8,
}}
onClick={openPopover}
>
<motion.span layoutId={`popover-label-${uniqueId}`} className="text-sm">
{children}
</motion.span>
</motion.button>
)
}
interface PopoverContentProps {
children: React.ReactNode
className?: string
}
export function PopoverContent({ children, className }: PopoverContentProps) {
const { isOpen, closePopover, uniqueId } = usePopover()
const formContainerRef = useRef<HTMLDivElement>(null)
useClickOutside(formContainerRef, closePopover)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closePopover()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => {
document.removeEventListener("keydown", handleKeyDown)
}
}, [closePopover])
return (
<AnimatePresence>
{isOpen && (
<motion.div
ref={formContainerRef}
layoutId={`popover-${uniqueId}`}
className={cn(
"absolute h-[200px] w-[364px] overflow-hidden border border-zinc-950/10 bg-white outline-none dark:bg-zinc-700 z-50", // Changed z-90 to z-50
className
)}
style={{
borderRadius: 12,
top: "auto", // Remove any top positioning
left: "auto", // Remove any left positioning
transform: "none", // Remove any transform
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
)
}
interface PopoverFormProps {
children: React.ReactNode
onSubmit?: (note: string) => void
className?: string
}
export function PopoverForm({
children,
onSubmit,
className,
}: PopoverFormProps) {
const { note, closePopover } = usePopover()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit?.(note)
closePopover()
}
return (
<form
className={cn("flex h-full flex-col", className)}
onSubmit={handleSubmit}
>
{children}
</form>
)
}
interface PopoverLabelProps {
children: React.ReactNode
className?: string
}
export function PopoverLabel({ children, className }: PopoverLabelProps) {
const { uniqueId, note } = usePopover()
return (
<motion.span
layoutId={`popover-label-${uniqueId}`}
aria-hidden="true"
style={{
opacity: note ? 0 : 1,
}}
className={cn(
"absolute left-4 top-3 select-none text-sm text-zinc-500 dark:text-zinc-400",
className
)}
>
{children}
</motion.span>
)
}
interface PopoverTextareaProps {
className?: string
}
export function PopoverTextarea({ className }: PopoverTextareaProps) {
const { note, setNote } = usePopover()
return (
<textarea
className={cn(
"h-full w-full resize-none rounded-md bg-transparent px-4 py-3 text-sm outline-none",
className
)}
autoFocus
value={note}
onChange={(e) => setNote(e.target.value)}
/>
)
}
interface PopoverFooterProps {
children: React.ReactNode
className?: string
}
export function PopoverFooter({ children, className }: PopoverFooterProps) {
return (
<div
key="close"
className={cn("flex justify-between px-4 py-3", className)}
>
{children}
</div>
)
}
interface PopoverCloseButtonProps {
className?: string
}
export function PopoverCloseButton({ className }: PopoverCloseButtonProps) {
const { closePopover } = usePopover()
return (
<button
type="button"
className={cn("flex items-center", className)}
onClick={closePopover}
aria-label="Close popover"
>
<X size={16} className="text-zinc-900 dark:text-zinc-100" />
</button>
)
}
interface PopoverSubmitButtonProps {
className?: string
}
export function PopoverSubmitButton({ className }: PopoverSubmitButtonProps) {
return (
<button
className={cn(
"relative ml-1 flex h-8 shrink-0 scale-100 select-none appearance-none items-center justify-center rounded-lg border border-zinc-950/10 bg-transparent px-2 text-sm text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-800 focus-visible:ring-2 active:scale-[0.98] dark:border-zinc-50/10 dark:text-zinc-50 dark:hover:bg-zinc-800",
className
)}
type="submit"
aria-label="Submit note"
>
Submit
</button>
)
}
export function PopoverHeader({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<div
className={cn(
"px-4 py-2 font-semibold text-zinc-900 dark:text-zinc-100",
className
)}
>
{children}
</div>
)
}
export function PopoverBody({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return <div className={cn("p-4", className)}>{children}</div>
}
// New component: PopoverButton
export function PopoverButton({
children,
onClick,
className,
}: {
children: React.ReactNode
onClick?: () => void
className?: string
}) {
return (
<button
className={cn(
"flex w-full items-center gap-2 rounded-md px-4 py-2 text-left text-sm hover:bg-zinc-100 dark:hover:bg-zinc-700",
className
)}
onClick={onClick}
>
{children}
</button>
)
}
Update the import paths to match your project setup.
Usage
import {
PopoverCloseButton,
PopoverContent,
PopoverFooter,
PopoverForm,
PopoverLabel,
PopoverRoot,
PopoverSubmitButton,
PopoverTextarea,
PopoverTrigger,
} from "@/components/Popover"
export default function PopoverDemo() {
const handleSubmit = (note: string) => {
console.log('Submitted note:', note)
}
return (
<div className="p-8">
<h1 className="mb-4 text-2xl font-bold">Headless Composable Popover Demo</h1>
<PopoverRoot>
<PopoverTrigger>Add Note</PopoverTrigger>
<PopoverContent>
<PopoverForm onSubmit={handleSubmit}>
<PopoverLabel>Add Note</PopoverLabel>
<PopoverTextarea />
<PopoverFooter>
<PopoverCloseButton />
<PopoverSubmitButton />
</PopoverFooter>
</PopoverForm>
</PopoverContent>
</PopoverRoot>
</div>
)
}
Popover Component
The Popover component is a headless, composable component that provides a flexible and customizable popover functionality. It uses Framer Motion for smooth animations and React context for state management.
PopoverRoot
The PopoverRoot
component is the main wrapper for the Popover. It provides the context and configuration for all child components.
<PopoverRoot>
{/* Other Popover components */}
</PopoverRoot>
PopoverTrigger
The PopoverTrigger
component is used to trigger the opening of the popover. It can wrap any clickable element.
<PopoverTrigger>Add Note</PopoverTrigger>
PopoverContent
The PopoverContent
component contains the main content of the popover. It handles the animation and positioning of the popover.
<PopoverContent>
{/* Popover content */}
</PopoverContent>
PopoverForm
The PopoverForm
component is used to create a form within the popover. It handles form submission and provides an onSubmit
prop for custom submission logic.
<PopoverForm onSubmit={handleSubmit}>
{/* Form fields */}
</PopoverForm>
PopoverLabel
The PopoverLabel
component is used to add a label to the popover content. It animates with the popover opening and closing.
<PopoverLabel>Add Note</PopoverLabel>
PopoverTextarea
The PopoverTextarea
component provides a textarea input for the popover form.
<PopoverTextarea />
PopoverFooter
The PopoverFooter
component is used to create a footer section in the popover, typically containing action buttons.
<PopoverFooter>
{/* Footer content */}
</PopoverFooter>
PopoverCloseButton
The PopoverCloseButton
component provides a button to close the popover.
<PopoverCloseButton />
PopoverSubmitButton
The PopoverSubmitButton
component provides a submit button for the popover form.
<PopoverSubmitButton />
Customization
The Popover component is highly customizable. You can modify the styles of each sub-component by passing className props or by wrapping them in your own styled components. The animation behavior can be adjusted by modifying the TRANSITION
object in the component's source code.
Accessibility
The Popover component includes basic accessibility features such as:
- Keyboard navigation support (Escape key to close)
- Proper ARIA attributes
- Focus management
However, depending on your specific use case, you may need to add additional accessibility features to ensure full compliance with WCAG guidelines.