Skip to content

Popover

A non-modal layer with six CSS placements — the page behind stays interactive.

Overlayintermediatepopoveroverlaysettingsnon-modal

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

'use client'

import { Info, SlidersHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/choice'
import { Popover } from '@/components/ui/popover'
import { DemoRow, DemoStage } from './_kit'

export default function PopoverDemo() {
  return (
    <DemoStage>
      <DemoRow
        label="Non-modal layer"
        description="The page stays interactive; Tab out to dismiss."
      >
        <Popover
          label="Column settings"
          trigger={(props) => (
            <Button
              variant="outline"
              leadingIcon={<SlidersHorizontal className="size-4" />}
              {...props}
            >
              Columns
            </Button>
          )}
        >
          <fieldset className="flex flex-col gap-1">
            <legend className="label-caps mb-1 text-ink-subtle">Visible columns</legend>
            <Checkbox id="col-name" name="columns" value="name" defaultChecked label="Name" />
            <Checkbox id="col-status" name="columns" value="status" defaultChecked label="Status" />
            <Checkbox id="col-region" name="columns" value="region" label="Region" />
            <Checkbox
              id="col-updated"
              name="columns"
              value="updated"
              defaultChecked
              label="Last updated"
            />
          </fieldset>
        </Popover>

        <Popover
          placement="bottom-end"
          label="Billing explanation"
          trigger={(props) => (
            <Button variant="ghost" size="icon" aria-label="About proration" {...props}>
              <Info className="size-4" />
            </Button>
          )}
        >
          <p className="text-sm text-ink-muted">
            Adding a seat mid-cycle charges the remaining days at the daily rate. Removing a seat
            credits the next invoice.
          </p>
        </Popover>
      </DemoRow>

      <DemoRow label="Placement">
        <Popover
          placement="top-start"
          label="Top placement"
          trigger={(props) => (
            <Button variant="soft" {...props}>
              Opens upward
            </Button>
          )}
        >
          <p className="text-sm text-ink-muted">
            Six placements cover every use in the library without a positioning engine.
          </p>
        </Popover>
      </DemoRow>
    </DemoStage>
  )
}

components/demos/popover.tsx

'use client'

import { Info, SlidersHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/choice'
import { Popover } from '@/components/ui/popover'
import { DemoRow, DemoStage } from './_kit'

export default function PopoverDemo() {
  return (
    <DemoStage>
      <DemoRow
        label="Non-modal layer"
        description="The page stays interactive; Tab out to dismiss."
      >
        <Popover
          label="Column settings"
          trigger={(props) => (
            <Button
              variant="outline"
              leadingIcon={<SlidersHorizontal className="size-4" />}
              {...props}
            >
              Columns
            </Button>
          )}
        >
          <fieldset className="flex flex-col gap-1">
            <legend className="label-caps mb-1 text-ink-subtle">Visible columns</legend>
            <Checkbox id="col-name" name="columns" value="name" defaultChecked label="Name" />
            <Checkbox id="col-status" name="columns" value="status" defaultChecked label="Status" />
            <Checkbox id="col-region" name="columns" value="region" label="Region" />
            <Checkbox
              id="col-updated"
              name="columns"
              value="updated"
              defaultChecked
              label="Last updated"
            />
          </fieldset>
        </Popover>

        <Popover
          placement="bottom-end"
          label="Billing explanation"
          trigger={(props) => (
            <Button variant="ghost" size="icon" aria-label="About proration" {...props}>
              <Info className="size-4" />
            </Button>
          )}
        >
          <p className="text-sm text-ink-muted">
            Adding a seat mid-cycle charges the remaining days at the daily rate. Removing a seat
            credits the next invoice.
          </p>
        </Popover>
      </DemoRow>

      <DemoRow label="Placement">
        <Popover
          placement="top-start"
          label="Top placement"
          trigger={(props) => (
            <Button variant="soft" {...props}>
              Opens upward
            </Button>
          )}
        >
          <p className="text-sm text-ink-muted">
            Six placements cover every use in the library without a positioning engine.
          </p>
        </Popover>
      </DemoRow>
    </DemoStage>
  )
}

components/ui/popover.tsx

'use client'

import { useId, useRef, useState, type ReactNode } from 'react'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'
import { getFocusable } from '@/hooks/use-focus-trap'

/**
 * Popover
 *
 * A non-modal layer: the page behind stays interactive and scroll is not
 * locked. Focus moves into the panel on open so keyboard users are not left
 * behind, but it is not trapped — Tab is allowed to leave, which closes it.
 *
 * Positioning is pure CSS (absolute + translate) rather than a positioning
 * engine; the placements below cover every use in the library and cost no
 * dependency.
 */
export type PopoverPlacement =
  | 'bottom'
  | 'bottom-start'
  | 'bottom-end'
  | 'top'
  | 'top-start'
  | 'top-end'

export interface PopoverProps {
  /** Render prop receives the props the trigger must spread. */
  trigger: (props: {
    'aria-expanded': boolean
    'aria-haspopup': 'dialog'
    'aria-controls': string
    onClick: () => void
    ref: React.Ref<HTMLButtonElement>
  }) => ReactNode
  children: ReactNode
  placement?: PopoverPlacement
  label: string
  className?: string
  panelClassName?: string
}

const placements: Record<PopoverPlacement, string> = {
  bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
  'bottom-start': 'top-full left-0 mt-2',
  'bottom-end': 'top-full right-0 mt-2',
  top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
  'top-start': 'bottom-full left-0 mb-2',
  'top-end': 'bottom-full right-0 mb-2',
}

export function Popover({
  trigger,
  children,
  placement = 'bottom-start',
  label,
  className,
  panelClassName,
}: PopoverProps) {
  const [open, setOpen] = useState(false)
  const rootRef = useRef<HTMLDivElement>(null)
  const triggerRef = useRef<HTMLButtonElement>(null)
  const panelRef = useRef<HTMLDivElement>(null)
  const id = useId()
  const panelId = `${id}-popover`

  useDismiss([rootRef], open, () => {
    setOpen(false)
    triggerRef.current?.focus()
  })

  const toggle = () => {
    setOpen((current) => {
      const next = !current
      if (next) {
        requestAnimationFrame(() => {
          const panel = panelRef.current
          if (!panel) return
          const first = getFocusable(panel)[0]
          ;(first ?? panel).focus({ preventScroll: true })
        })
      }
      return next
    })
  }

  return (
    <div ref={rootRef} className={cn('relative inline-block', className)}>
      {trigger({
        'aria-expanded': open,
        'aria-haspopup': 'dialog',
        'aria-controls': panelId,
        onClick: toggle,
        ref: triggerRef,
      })}
      {open ? (
        <div
          ref={panelRef}
          id={panelId}
          role="dialog"
          aria-label={label}
          tabIndex={-1}
          className={cn(
            'animate-scale-in absolute z-50 w-72 rounded-lg border border-line bg-surface-raised p-4 shadow-md',
            placements[placement],
            panelClassName,
          )}
        >
          {children}
        </div>
      ) : null}
    </div>
  )
}

hooks/use-dismiss.ts

'use client'

import { useEffect, type RefObject } from 'react'

/**
 * Closes a layer on Escape or on a pointer press outside it.
 *
 * `pointerdown` is used rather than `click` so that dragging a text selection
 * out of a popover does not dismiss it, and Escape is captured on the document
 * so it works even when focus has moved into a nested portal.
 */
export function useDismiss(
  refs: Array<RefObject<HTMLElement | null>>,
  active: boolean,
  onDismiss: () => void,
  options: { escape?: boolean; outside?: boolean } = {},
): void {
  const { escape = true, outside = true } = options

  useEffect(() => {
    if (!active) return

    const onKeyDown = (event: KeyboardEvent) => {
      if (!escape) return
      if (event.key === 'Escape') {
        event.stopPropagation()
        onDismiss()
      }
    }

    const onPointerDown = (event: PointerEvent) => {
      if (!outside) return
      const target = event.target as Node | null
      if (!target) return
      const inside = refs.some((ref) => ref.current?.contains(target))
      if (!inside) onDismiss()
    }

    document.addEventListener('keydown', onKeyDown)
    document.addEventListener('pointerdown', onPointerDown)
    return () => {
      document.removeEventListener('keydown', onKeyDown)
      document.removeEventListener('pointerdown', onPointerDown)
    }
    // `refs` is a stable-length array supplied by the caller at each layer.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active, escape, outside, onDismiss])
}

/** Locks body scroll while a modal layer is open, without layout shift. */
export function useScrollLock(active: boolean): void {
  useEffect(() => {
    if (!active) return
    const { body, documentElement } = document
    const scrollBarWidth = window.innerWidth - documentElement.clientWidth
    const previousOverflow = body.style.overflow
    const previousPadding = body.style.paddingRight

    body.style.overflow = 'hidden'
    if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`

    return () => {
      body.style.overflow = previousOverflow
      body.style.paddingRight = previousPadding
    }
  }, [active])
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For secondary controls and explanations that should not interrupt: column pickers, filter summaries, "what does this mean". If the user must deal with it before continuing, it is a Dialog.

  • Positioning is pure CSS. Six placements cover every use in this library without a positioning dependency.
  • Focus moves into the panel on open but is not trapped — Tab is allowed to leave, which closes it.
  • A popover may contain interactive content; a tooltip may not.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Six placements
  • With form controls
  • As an explanation

Accessibility

Roles
Trigger carries `aria-haspopup="dialog"` and `aria-expanded`; the panel is a labelled `role="dialog"`.
Dismissal
Escape closes and returns focus to the trigger; an outside press closes without stealing focus.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.