Popover
Non-blocking floating content.
Motion: Glass panel opens in place over its trigger — the trigger fades and the pill becomes the panel — and folds back into it on close. Beside the trigger with PopoverAnchor or `inPlace={false}`.
Installation
npx shadcn@latest add @craft/popoverSource
components/ui/popover.tsx
"use client"
import * as React from "react"
import { cn } from "cn"
import { Popover as PopoverPrimitive } from "radix-ui"
import { useMergedRef, useMorphOriginStore, useOriginMorph, type MorphOrigin } from "@/lib/morph"
type PopoverMorphContext = {
/** The trigger that opened the panel — it morphs out of it and back into it */
origin: MorphOrigin
open: boolean
/** Set by <PopoverAnchor>: the panel then opens beside the anchor instead of over the trigger */
anchored: boolean
setAnchored: (anchored: boolean) => void
}
const PopoverMorphContext = React.createContext<PopoverMorphContext | null>(null)
/**
* Glass panel that morphs out of its <PopoverTrigger>: by default it opens
* **in place**, over the trigger, which hides while it's open — the pill
* becomes the panel and folds back into it on close (lib/morph.ts). Radix
* keeps positioning: with no room below it grows upward, and it shifts to
* stay on screen. With a <PopoverAnchor> (e.g. DateInput) or
* `inPlace={false}` on the content, it opens beside the trigger instead.
*/
function Popover({
open: openProp,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
const origin = useMorphOriginStore()
// Tracked here so PopoverContent re-renders on open and can place itself over the trigger
const [inner, setInner] = React.useState(defaultOpen)
const open = openProp ?? inner
const [anchored, setAnchored] = React.useState(false)
const value = React.useMemo(() => ({ origin, open, anchored, setAnchored }), [origin, open, anchored])
return (
<PopoverMorphContext.Provider value={value}>
<PopoverPrimitive.Root
data-slot="popover"
open={open}
onOpenChange={(next) => {
setInner(next)
onOpenChange?.(next)
}}
{...props}
/>
</PopoverMorphContext.Provider>
)
}
function PopoverTrigger({
onClick,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
const ctx = React.useContext(PopoverMorphContext)
return (
<PopoverPrimitive.Trigger
data-slot="popover-trigger"
onClick={(e) => {
ctx?.origin.set(e.currentTarget)
onClick?.(e)
}}
{...props}
/>
)
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
inPlace = true,
ref,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
/** Open over the trigger (default) or beside it like a classic popover */
inPlace?: boolean
}) {
const ctx = React.useContext(PopoverMorphContext)
// `open` in context re-renders this on open; the trigger stays known after close, so the mode is stable while folding back
const trigger = ctx?.open !== undefined ? ctx.origin.get() : null
const overTrigger = inPlace && !ctx?.anchored && !!trigger?.isConnected
const morphRef = useOriginMorph(ctx?.origin ?? null, { waitForPosition: true, hideOrigin: overTrigger })
const mergedRef = useMergedRef(morphRef, ref)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={mergedRef}
data-slot="popover-content"
data-in-place={overTrigger || undefined}
align={align}
// A negative offset of the trigger's height lays the panel over it (Radix flips this upward too)
sideOffset={overTrigger && trigger ? -trigger.offsetHeight : sideOffset}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-2xl glass p-3 text-sm text-popover-foreground ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
const setAnchored = React.useContext(PopoverMorphContext)?.setAnchored
React.useEffect(() => {
setAnchored?.(true)
return () => setAnchored?.(false)
}, [setAnchored])
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}