Floating Panel
Headless floating surface primitive. Composes Portal, presence, and floating-position to render an anchored, animated panel with data-state, data-side, data-align, and data-positioned attributes plus a transform-origin custom property. Used by Popover, Select, and other anchored surfaces.
Installation
dgadd is not public on npm yet. Until the first release, pack @diffgazer/add from the repository and install that tarball into this app, which is what puts dgadd on pnpm exec.
The hosted registry is not public yet because r.b4r7.dev does not resolve. Use this source checkout or a local registry preview until the endpoint returns 200.
Diffgazer packages are not yet published to npm. Until the first release, pack @diffgazer/ui and @diffgazer/keys from the repository and install those tarballs.
UI components require Tailwind CSS v4. Local copy mode imports src/styles/styles.css; package mode uses @diffgazer/ui CSS once packages are available.
Usage
FloatingPanel never closes itself. Wrap it in a primitive that owns dismiss (outside-click, escape, focus) and forward the resolved open boolean. The custom menu example below shows the pattern.
Examples
Custom menu
Edge collision
API Reference
FloatingPanel
useFloatingPanelContext
Data attributes
CSS variables
Accessibility
FloatingPanel renders a bare <div>. Consumers must supply:
- a
role(for example"dialog","menu","listbox","tooltip") - an accessible name via
aria-labeloraria-labelledby
Descendants of the rendered panel can read positioning state via useFloatingPanelContext():
positioned is false until the first measure resolves. Use it to defer effects (focus, measurement) until after that first measure — this is how PopoverContent defers autoFocus.
Notes
Headless and Controlled
FloatingPanel never closes itself. There is no defaultOpen. Wrap it in a primitive that owns dismiss (outside-click, escape, focus management) and forward the resolved boolean to `open`.
Positioning
Resolves placement against `triggerRef` with `side`, `align`, `sideOffset`, and `alignOffset`. When `avoidCollisions` is true (default), the panel flips to the opposite side, then cross-axis sides, then shifts within the viewport. If no side fits, it takes the side that overflows least rather than the preferred one, and its size caps come from the padded viewport it is about to be shifted into — so a panel anchored to a trigger with no room left never collapses to zero. Final values land on `data-side` and `data-align`.
CSS Custom Properties
Always writes `--ui-content-transform-origin` derived from the resolved side/align plus `--floating-panel-available-height` and `--floating-panel-available-width` for capping overflow. When `matchTriggerWidth` is true, also writes `--ui-floating-trigger-width`. The `.ui-floating-panel` rule reads `--ui-floating-z` (default `var(--z-popover)`) for its z-index layer, so consumers can scope-override z without className overrides. Consumers can read or override these on the panel or an ancestor.
Anchor Tracking
The panel re-measures on scroll of every scrollable ancestor, on window scroll/resize, and on trigger/panel resize, so it stays attached while the page moves. Once the trigger scrolls fully out of the viewport or out of one of those scroll ancestors, collision clamping would park the panel against a viewport edge detached from its anchor; instead the panel marks itself `data-anchor-hidden` and stops painting (`opacity: 0`, `pointer-events: none`). It stays mounted and focusable so an open overlay never drops focus, and it paints again as soon as the anchor scrolls back into view.
Style Merging
Caller `style` merges before internal positioning styles. Structural keys (`position`, `top`, `left`, `visibility`, `max-width`, `max-height`, `--ui-content-transform-origin`, `--floating-panel-available-height`, `--floating-panel-available-width`, `--ui-floating-trigger-width`, plus `opacity`/`pointer-events`/`animation` while `data-anchor-hidden` is set) cannot be overridden; everything else (background, min-width, border, transform, etc.) passes through. When `data-anchor-hidden` clears, the inline `animation: none` is removed and any consumer CSS enter animation restarts from frame zero — a deliberate trade-off: the hidden panel must not paint mid-keyframe, at the cost of replaying the animation on un-hide. The `max-width`/`max-height` caps hold the panel inside the collision padding, and the panel is its own scroll container (`overflow: auto` from `.ui-floating-panel`), so content beyond either cap scrolls inside the panel instead of running off the viewport edge. A consumer who overrides `overflow` — e.g. a panel that intentionally paints outside its box — owns the resulting sizing. The caps are omitted entirely when `avoidCollisions` is false; the `--floating-panel-available-*` custom properties are still written, so an opted-out consumer can cap by hand.
Accessibility
FloatingPanel renders a bare div. Consumers must supply a role (e.g. `dialog`, `menu`) and an accessible name (`aria-label` or `aria-labelledby`).
Context
Descendants of the rendered panel can subscribe to positioning state via `useFloatingPanelContext()`. Useful for adapters that need to defer effects (focus, measurement) until after the first measure.
CSS variables
The panel reads these CSS custom properties from the .ui-floating-panel cascade. Override on the panel, an ancestor, or :root. The animation tokens collapse to fade under prefers-reduced-motion: reduce (see Theme).
Override one direction with a transform-based keyframe:
Lower the panel below other surfaces by scoping --ui-floating-z:
Reduced motion
Under prefers-reduced-motion: reduce, the four --ui-content-enter-from-* tokens collapse to the fade-only keyframe (ui-content-enter-fade) and the four --ui-content-exit-to-* tokens collapse to ui-content-exit-fade. Directional motion is neutralized; opacity transitions still run because they communicate state without simulating motion.
Per-instance overrides win against the global reduced-motion fallback. If you need different motion under reduced motion for a specific panel, scope the override yourself with @media (prefers-reduced-motion: reduce).
FAQ
The panel does not appear. Check that triggerRef.current resolves to a DOM element before open flips to true. FloatingPanel measures against triggerRef.current on first paint; if the trigger has not mounted, no position resolves and the panel stays hidden.
The panel vanishes when I scroll. That is anchor tracking, not a bug. The panel re-measures against the trigger on every scroll and resize; once the trigger leaves the viewport (or the scrollable ancestor it lives in) the panel sets data-anchor-hidden and stops painting, because a clamped position detached from its anchor reads as a stray box floating over unrelated content. It stays mounted and focusable, and paints again as soon as the anchor scrolls back in. Close the panel on scroll in the wrapping primitive if you want it gone for good.
My custom transform animation is not anchored. Set transform-origin: var(--ui-content-transform-origin) inside your @keyframes rule. FloatingPanel writes the origin to the element on every measure based on the resolved side and align.
useFloatingPanelContext() throws. The hook only works inside a rendered FloatingPanel. When the panel is closed, no provider mounts. Read the context from a child component rendered within <FloatingPanel> children, not from the wrapping primitive.
The exit animation never finishes. FloatingPanel waits for animationend and falls back to exitFallbackMs (default 1000). If you raise --ui-content-exit-duration past 500ms, raise exitFallbackMs to at least 2× the new value.
Source
Install via CLI: pnpm exec dgadd add ui/floating-panel.
Highlighted source loads after this disclosure opens. Browse the source repository.