Zubeyr··ComponentsoverlaysPopover
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
'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
'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>
)
}