Popover
A click-triggered floating panel that portals past clipping ancestors, then flips, shifts and size-caps itself against the nearest scrollable boundary.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/popover.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Popover" component (React 19 + react-dom
only; no positioning library, no Radix, no Floating UI).
Contract
- export const Popover = React.forwardRef<HTMLDivElement, PopoverProps>, where
PopoverProps extends React.HTMLAttributes<HTMLDivElement>:
- trigger: React.ReactNode — required
- open? / defaultOpen? (false) / onOpenChange?: (open: boolean) => void
- side? = "top" | "right" | "bottom" | "left" (default "bottom")
- align? = "start" | "center" | "end" (default "center")
- sideOffset? = number (default 8, clamped to >= 0; non-finite falls back to 8)
- modal? = boolean (default false)
- closeOnOutside? = boolean (default true)
- children — panel content; className merged last onto the PANEL via cn()
- Controlled when `open` is passed, uncontrolled otherwise; onOpenChange fires
for every open/close intent in both modes.
- The forwarded ref points at the panel, which only exists while open. Remaining
props (aria-label, aria-labelledby, data-*) spread onto the panel — spread
them BEFORE the computed style, or a consumer `style` overwrites the
coordinates and drops the panel at 0,0.
- Trigger: a valid React element is cloned (asChild semantics) and receives
aria-expanded, aria-haspopup="dialog", aria-controls (only while open, so the
id always resolves), data-state, and a merged onClick that respects
event.defaultPrevented. A non-element trigger (a string) is wrapped in a
default button. Either way the trigger renders inside an `inline-flex` anchor
span, and THAT span is what gets measured — so the component never depends on
the consumer's trigger forwarding a ref.
Behavior — positioning (the hard part; get this right first)
- The panel renders through createPortal into document.body with
position: fixed. This is the anti-clipping mechanism: an in-flow absolutely
positioned panel inside an overflow-hidden card is drawn INSIDE the clip —
present in the DOM, invisible and unclickable. A portalled panel is not a
descendant of that card, so nothing can clip it.
- Boundary = the window, intersected with every ancestor whose computed overflow
is auto|scroll. Ancestors that are merely overflow:hidden/clip are
deliberately NOT boundaries: the portal already escaped them, and clamping a
settings form into a 200px decorative card would "fix" the clipping by
crushing the panel instead. A scroll container IS a boundary, because it is a
real viewport for the trigger and a panel spilling out of one would float over
unrelated content.
- One pass computes everything: measure -> flip -> cap -> align -> shift.
1. Read the anchor rect and the panel's NATURAL size, obtained by momentarily
setting the panel's inline max-width/max-height to "none", reading
offsetWidth/offsetHeight, then restoring them — and restoring the scroll
area's scrollTop/scrollLeft, which collapsing the cap resets. Measuring the
already-capped panel instead would make it always look like it "fits", and
the placement would oscillate on every resize.
2. FLIP to the opposite side only when the preferred side has less room than
the panel needs AND the opposite side has strictly more room — a panel too
tall for both sides stays where the author asked and scrolls.
3. CAP: for a vertical side, maxHeight = the space that exists on that side
and maxWidth = the whole boundary width; mirrored for a horizontal side.
Never below a 96px floor.
4. ALIGN on the cross axis (start / center / end against the trigger), then
SHIFT: clamp into the boundary, so a start-aligned panel near the right
edge slides left instead of hanging off it.
- Overflow lives inside: the panel is a flex column carrying the cap, and its
single child is `min-h-0 flex-1 overflow-auto`, so content taller than the
available room scrolls rather than being clipped.
- Measurement runs inside a ResizeObserver callback observing the panel and the
anchor — never synchronously in an effect body. ResizeObserver fires once
right after observe(), after layout and before paint, so that first callback
IS the initial measurement. Until it lands the panel carries
`pointer-events-none opacity-0` — deliberately NOT `invisible`. A
`visibility: hidden` subtree cannot take focus, so `focus()` on it fails
silently, and everything downstream of focus dies with it: Escape never
reaches the panel handler, the content is unreachable by keyboard, the Tab
trap has nothing to trap, and a modal popover leaks its body scroll lock
because it can never be closed from the keyboard. `opacity-0` hides that one
frame while keeping the panel measurable AND focusable, so
no frame is ever painted at 0,0. Reposition on window `scroll` (capture phase,
so scrolls inside nested containers are caught; passive; rAF-throttled) and on
`resize`; ignore scroll events originating inside the panel. Disconnect the
observer, remove both listeners and cancel the pending frame on close/unmount.
- Guard the state write with a field-by-field equality check: applying our own
max-width/max-height resizes the panel and re-fires the observer, and bailing
on an unchanged result turns that into one no-op callback instead of a loop.
Behavior — dismissal, focus, modality
- Opening moves focus to the first focusable element in the panel, or to the
panel itself (tabIndex={-1}) when it has none — but only once the first
measurement has landed. Gate that effect on a plain boolean derived from the
layout state (`positioned = layout !== null`), not on the layout object
itself, so later repositions from scroll/resize never re-run it and yank
focus back out of wherever the user has moved it. The gate is not cosmetic:
before coordinates exist the panel is hidden for that frame, and focusing it
too early is what silently strands the whole keyboard path (see the
`opacity-0` note above). Closing returns focus to the
trigger — but only after an isConnected check, because whatever the popover
just did often unmounted it, and focusing a detached node silently drops focus
onto the body element.
- Focus is NOT returned when the popover was dismissed by an outside
interaction: the user is already somewhere else and yanking focus back is
hostile. Track that with a ref the outside handlers set to false.
- Escape closes and returns focus. Handle it on the panel (not on window) and
stopPropagation, so a popover nested inside another overlay closes the
innermost one only; focus always lives inside the panel while open, so a
panel-level handler is sufficient.
- closeOnOutside (default true) installs two document listeners while open:
`pointerdown` outside the panel and anchor (pointerdown, not click, so the
panel is gone before the press turns into a click on whatever is underneath),
and `focusin` outside them for non-modal popovers — Tab out of a portalled
panel lands somewhere unrelated in DOM order, and a non-modal popover reads
that as "the user left".
- modal={true} adds a scrim (fixed inset-0, bg-background/60), aria-modal="true"
and a REAL focus trap: Tab / Shift+Tab cycle between the first and last
visible focusable in the panel (handled in the same panel onKeyDown as
Escape).
- The modal body scroll lock keeps its reentrancy count AND its pre-lock
snapshot on `document.body` as data attributes
(`body.dataset.zyScrollLocks`, `.zyScrollLockOverflow`,
`.zyScrollLockPadding`), never in module-level variables. The 0 → 1 edge
snapshots body's current inline `overflow` / `paddingRight` and freezes;
later locks only increment; only the 1 → 0 release writes the snapshot back
and deletes all three attributes. Module scope is not good enough here
because every component is installed as its own copy: a page runs several
independent copies of this same lock (this popover, a drawer, a loading
overlay), each with a private counter that cannot see the others. Nest two
and the outer restores "" on close while the inner later writes back the
"hidden" it recorded as the original — the page is locked until a reload,
with no overlay left on screen to explain it. A body attribute is the one
namespace independent copies already share; keep the three names
byte-identical wherever this code is pasted.
- Scrollbar compensation is MEASURED, not predicted: read
`document.documentElement.clientWidth`, set `overflow: hidden`, read it
again, and add the positive difference to body's computed `paddingRight`.
The `innerWidth - clientWidth` shortcut is wrong on any page with
`scrollbar-gutter: stable`, where the gutter is permanent and no width is
reclaimed: it pads ~15px that nothing gave back and shifts the content LEFT
as the popover opens. Measuring also makes the branch a no-op under macOS
overlay scrollbars, which is exactly why a Mac-only test proves nothing.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border /
shadow-md, bg-background/60 for the scrim, ring-ring for focus rings. No
hardcoded colors, no palette classes.
- Panel defaults: fixed z-50 flex w-72 flex-col overflow-hidden rounded-lg border
p-4 outline-none. className is merged last through cn(), so w-96 / p-0 /
rounded-xl all win.
- Enter animation: one keyframe per side (opacity 0 -> 1 with a 4px slide away
from the trigger and scale 0.98 -> 1, 140ms ease-out), shipped in a single
React 19 hoisted style tag with an href and precedence, so N popovers emit one
rule set and no Tailwind config edit is needed.
motion-reduce:[animation:none] removes the motion while opening and closing
still work.
- Expose data-side (the RESOLVED side, after flipping) and data-state on the
panel so consumers can style an arrow or a directional shadow.
- "use client" is required: state, effects, portal and DOM measurement.
Customization levers
- Size: className="w-96" (or w-[32rem]) changes the natural width; the flip and
cap math reads the natural size, so nothing else has to change. Padding is
p-4 on the panel — set p-0 and pad your own content for a full-bleed header.
- Distance and placement: sideOffset for the gap, side/align for the preferred
spot. EDGE_MARGIN (8) controls how close the panel may get to the boundary,
MIN_PANEL_SIZE (96) the smallest it may be squeezed to before it just scrolls.
- Motion: swap the four keyframes for a single fade if the directional slide is
too playful, or raise 140ms to ~200ms with a springier cubic-bezier. Keep the
motion-reduce escape hatch either way.
- Modality: modal={false} + closeOnOutside={false} gives a pinned inspector that
only Escape and the trigger can close; modal={true} gives a lightweight dialog
that still points at its trigger.
- Boundary policy — the one deliberate opinion. To clamp inside purely
overflow-hidden hosts as well, widen the ancestor test from /auto|scroll/ to
"any non-visible overflow", and accept that small hosts will squeeze the panel.
- Turning this into a menu: keep the positioning core, swap role="dialog" for
role="menu" plus roving-tabindex arrow navigation, and open on hover instead
of click for a hover card. The measure/flip/cap/shift block is the reusable
half.Concepts
- Portal before geometry — the cure for "the panel is in the DOM but I cannot click it" is not smarter math, it is not being a descendant of the thing that clips. The panel is rendered into the document body with
position: fixed; only then is it worth computing where it should go. - Clipping ancestor vs scroll boundary — two ancestor kinds look identical to
getComputedStyleand mean opposite things.overflow: hiddenis decoration (a rounded card, a masked hero) and is already escaped by the portal, so it must not clamp the panel.overflow: auto | scrollis a real viewport for the trigger, so it does. Treating both as boundaries trades an invisible panel for a crushed one. - Flip and shift — flip is a main-axis decision (bottom becomes top when the opposite side is strictly roomier); shift is a cross-axis correction (a
start-aligned panel near the right edge slides left until it fits). Flip alone leaves the panel hanging off a corner; shift alone leaves it running off the bottom. - Available space as
maxHeight— rather than guessing a height, the panel is capped to the room that actually exists on the chosen side and its content area scrolls. That is what lets a long panel open beside a trigger sitting 40px from an edge without being clipped and without pushing the layout around. - Natural-size measurement — the flip decision reads the panel's size with its own caps momentarily lifted. Measuring a panel already capped by the previous pass makes it always look like it fits, which is exactly how hand-rolled poppers end up ping-ponging between sides on every resize.
- Measure before paint —
ResizeObserverfires once immediately afterobserve(), after layout and before paint, so the first callback doubles as the initial measurement with no synchronoussetStatein an effect body. The panel is hidden until coordinates exist, so no frame is ever painted at the top-left of the screen. - Hidden but still focusable — that first frame uses
pointer-events-none opacity-0, notinvisible. Browsers refuse to focus avisibility: hiddenelement andfocus()reports nothing when it fails, so the panel would look correct and be keyboard-dead: no Escape, no reachable content, a Tab trap with nothing to trap, and a modal instance holding a scroll lock it can never release. Focus is additionally deferred until the layout lands, so the two halves agree. - Scroll lock counted on
document.body— the count and the savedoverflow/paddingRightare data attributes onbody, not module-level variables, because each component is installed as a separate copy with its own module scope. Two copies with private counters cannot see each other: the outer overlay restores""on close, the inner one later hands back the"hidden"it recorded as the original, and the page never scrolls again with nothing on screen to blame. Shared attribute names are what make independent copies act like one lock. - Measured scrollbar compensation — the padding added while locked is the observed
clientWidthdelta acrossoverflow: hidden, notinnerWidth - clientWidth. Underscrollbar-gutter: stablethe gutter is permanent, so the prediction pads width that was never reclaimed and the page jumps left instead of staying still; the measurement also correctly does nothing under macOS overlay scrollbars, which is why this bug class survives Mac-only testing. - Focus return vs focus abandonment — Escape and the trigger return focus to the trigger (after an
isConnectedcheck, since the action often unmounted it). An outside click or an outside focus deliberately does not: the user has already moved on, and pulling focus back is what makes hand-rolled overlays feel possessed.
Drawer
An edge-anchored drawer with drag-to-dismiss, snap points and scroll-aware pull-to-close — hand-rolled, no vaul.
Hover Card
A hover-and-focus card for arbitrary rich content — avatar, stats, links, buttons — with an open delay, a close grace period the pointer can cross into, clip-aware flipping and tap-to-toggle on touch.