← Dashboard
Zubeyr··Componentsoverlays

Popover

A lightweight popover anchored to any trigger: opens beside or above it, stays on screen and escapes scrolling containers, closes on outside click or Escape, and hands a close() to its content.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/popover.json
  • →Files land in components/dashboard.

Usage

example.tsx
'use client'

import { Button } from '@/registry/dashboard/components/button/button'

import { Popover } from '@/components/dashboard/popover'

const options = ['Newest first', 'Oldest first', 'Highest total', 'Lowest total']

export default function Demo() {
  return (
    <Popover
      trigger={({ open, toggle }) => (
        <Button aria-expanded={open} onClick={toggle}>
          Sort orders
        </Button>
      )}
    >
      {(close) => (
        <ul role="menu">
          {options.map((o) => (
            <li key={o}>
              <button
                type="button"
                role="menuitem"
                onClick={close}
                className="flex h-8 w-full items-center rounded-lg px-2 text-left text-sm hover:bg-muted"
              >
                {o}
              </button>
            </li>
          ))}
        </ul>
      )}
    </Popover>
  )
}

Source code

popover.tsx
'use client'

import { useEffect, useLayoutEffect, useRef, useState } from 'react'

import { cn } from '@/lib/utils'

type PopoverProps = {
  trigger: (props: { open: boolean; toggle: () => void }) => React.ReactNode
  align?: 'start' | 'end'
  side?: 'top' | 'bottom'
  className?: string
  block?: boolean
  children: React.ReactNode | ((close: () => void) => React.ReactNode)
}

type Place = { top: number; left: number; minWidth: number }

export function placePanel(
  anchor: DOMRect,
  panel: { width: number; height: number },
  align: 'start' | 'end',
  side: 'top' | 'bottom',
): { top: number; left: number } {
  const gap = 6
  const below = window.innerHeight - anchor.bottom - gap
  const above = anchor.top - gap
  const up =
    side === 'top' ? above >= panel.height || above > below : below < panel.height && above > below
  const top = up ? anchor.top - gap - panel.height : anchor.bottom + gap
  const rawLeft = align === 'end' ? anchor.right - panel.width : anchor.left
  const left = Math.max(8, Math.min(rawLeft, window.innerWidth - panel.width - 8))
  return { top: Math.max(8, top), left }
}

export function useFloating(
  open: boolean,
  align: 'start' | 'end' = 'start',
  side: 'top' | 'bottom' = 'bottom',
) {
  const anchor = useRef<HTMLDivElement>(null)
  const panel = useRef<HTMLDivElement>(null)
  const [place, setPlace] = useState<Place | null>(null)

  useLayoutEffect(() => {
    if (!open) return
    const update = () => {
      if (!anchor.current || !panel.current) return
      const a = anchor.current.getBoundingClientRect()
      const p = panel.current.getBoundingClientRect()
      setPlace({ ...placePanel(a, p, align, side), minWidth: a.width })
    }
    update()
    window.addEventListener('scroll', update, true)
    window.addEventListener('resize', update)
    return () => {
      window.removeEventListener('scroll', update, true)
      window.removeEventListener('resize', update)
    }
  }, [open, align, side])

  return { anchor, panel, place }
}

export function Popover({
  trigger,
  align = 'start',
  side = 'bottom',
  className,
  block = false,
  children,
}: PopoverProps) {
  const [open, setOpen] = useState(false)
  const { anchor, panel, place } = useFloating(open, align, side)

  useEffect(() => {
    if (!open) return
    const onPointer = (event: PointerEvent) => {
      if (!anchor.current?.contains(event.target as Node)) setOpen(false)
    }
    document.addEventListener('pointerdown', onPointer)
    return () => document.removeEventListener('pointerdown', onPointer)
  }, [open, anchor])

  return (
    <div
      ref={anchor}
      className={cn('relative', block ? 'flex w-full' : 'inline-flex')}
      onKeyDown={(event) => {
        if (event.key !== 'Escape' || !open) return
        event.stopPropagation()
        setOpen(false)
      }}
    >
      {trigger({ open, toggle: () => setOpen(!open) })}
      {open && (
        <div
          ref={panel}
          style={{
            top: place?.top ?? 0,
            left: place?.left ?? 0,
            minWidth: place?.minWidth,
            visibility: place ? 'visible' : 'hidden',
          }}
          className={cn(
            'fixed z-[60] min-w-48 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-(--shadow-overlay)',
            className?.replace(/\bw-full\b/g, ''),
          )}
        >
          {typeof children === 'function' ? children(() => setOpen(false)) : children}
        </div>
      )}
    </div>
  )
}