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/popover

Source

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,
}