Skip to content

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.

Preview

Installation

$pnpm exec dgadd add ui/floating-panel
[Installs to]src/components/ui/floating-panel[Item]ui/floating-panel

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.

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

tsx
"use client";import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";import { FloatingPanel, useFloatingPanelContext } from "@/components/ui/floating-panel";export default function FloatingPanelDefaultExample() {  const [open, setOpen] = useState(false);  const triggerRef = useRef<HTMLButtonElement>(null);  const panelId = useId();  const titleId = useId();  const close = () => {    setOpen(false);    triggerRef.current?.focus();  };  const handlePanelKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {    if (event.key !== "Escape") return;    event.preventDefault();    close();  };  return (    <div className="flex items-center gap-4">      <button        ref={triggerRef}        type="button"        aria-haspopup="dialog"        aria-expanded={open}        aria-controls={open ? panelId : undefined}        onClick={() => setOpen((value) => !value)}        className="border border-foreground/30 px-3 py-1 font-mono text-sm focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-0"      >        {open ? "close" : "open panel"}      </button>      <FloatingPanel        open={open}        triggerRef={triggerRef}        role="dialog"        aria-labelledby={titleId}        id={panelId}        onKeyDown={handlePanelKeyDown}        className="rounded-sm border border-border bg-background p-3 font-mono text-xs text-foreground"      >        <DialogContent titleId={titleId} onDismiss={close} />      </FloatingPanel>    </div>  );}function DialogContent({ titleId, onDismiss }: { titleId: string; onDismiss: () => void }) {  const { positioned } = useFloatingPanelContext();  const dismissRef = useRef<HTMLButtonElement>(null);  useEffect(() => {    if (positioned) dismissRef.current?.focus();  }, [positioned]);  return (    <>      <p id={titleId} className="font-bold">        Quick info      </p>      <p>Anchored panel content.</p>      <button        ref={dismissRef}        type="button"        className="mt-2 block border border-foreground/30 px-2 py-0.5 text-xs focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-0"        onClick={onDismiss}      >        dismiss      </button>    </>  );}

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

Preview

Edge collision

Preview

API Reference

FloatingPanel

NameTypeDefaultDescription
openrequiredbooleanControlled open state. FloatingPanel never closes itself; the wrapping primitive owns dismiss and forwards the resolved boolean here.
triggerRefrequiredRefObject<HTMLElement | null>Anchor element the panel positions against. Must be a stable RefObject.
side"top" | "bottom" | "left" | "right""bottom"Preferred side relative to the trigger.
align"start" | "center" | "end""center"Alignment along the chosen side.
sideOffsetnumber6Pixel gap from the trigger along the side axis.
alignOffsetnumber0Pixel offset along the alignment axis.
avoidCollisionsbooleantrueFlips to the opposite side, then cross-axis sides, then shifts within the viewport. When false the panel is also left uncapped — no `max-width`/`max-height` is emitted.
collisionPaddingnumber8Minimum gap between the panel and the viewport edge during collision avoidance.
matchTriggerWidthbooleanfalseWhen true, exposes `--ui-floating-trigger-width` on the panel so callers can size the panel against the trigger (use as `width`, `min-width`, or `max-width`).
exitFallbackMsnumber1000Max ms to wait for `animationend` before forcing unmount. Raise to at least 2× `--ui-content-exit-duration` if you customize that token past 500ms.
portalContainerElement | nullExplicit portal target forwarded to Portal. Falls back to the ambient PortalContainerProvider scope, then the scoped container's `ownerDocument.body`, then `document.body`.
onExitComplete() => voidFired after the exit animation resolves (or the fallback timer fires) and the panel unmounts.
styleCSSPropertiesCaller styles merged before internal positioning styles. Structural keys cannot be overridden; pass-through keys (background, border, transform, etc.) apply.
classNamestringAdditional class names merged after the default `ui-floating-panel` class.
childrenReactNodePanel body content.
refRef<HTMLDivElement>Forwarded ref to the panel element. Composed with the internal measurement ref.

useFloatingPanelContext

NameTypeDefaultDescription
positionedrequiredbooleanTrue once a viewport position has been resolved and the panel has measured against its trigger; false during the first paint and after the exit animation. Use to defer effects (focus, measurement) until after the first measure.
siderequired"top" | "bottom" | "left" | "right" | nullPreferred side relative to the trigger; resolved after collision handling. Null before the first measure.
alignrequired"start" | "center" | "end" | nullAlignment along the chosen side; resolved align after collision handling. Null before the first measure.

Data attributes

AttributeApplies toValuesDescription
data-stateFloatingPanel"open" | "closed"Presence state for enter and exit animation selectors.
data-sideFloatingPanel"top" | "right" | "bottom" | "left"Resolved side after collision handling.
data-alignFloatingPanel"start" | "center" | "end"Resolved alignment after collision handling.
data-positionedFloatingPanelpresent after first measurementMarks a measured panel so adapters can defer effects until positioning is stable.
data-anchor-hiddenFloatingPanelpresent while the trigger is scrolled out of viewSet when the trigger has left the viewport or a scroll ancestor. The panel stops painting while it is present.

CSS variables

NameDefaultDescription
--ui-content-transform-origincomponent-definedComputed transform origin matching the resolved side and alignment.
--ui-floating-trigger-widthcomponent-definedTrigger width in pixels when matchTriggerWidth is true.
--floating-panel-available-heightcomponent-definedAvailable height before viewport overflow. Use as max-height with overflow-y: auto.
--floating-panel-available-widthcomponent-definedAvailable width before viewport overflow. Use as max-width with overflow-x: auto.
--ui-floating-zvar(--z-popover)Layer token read by .ui-floating-panel for z-index.

Accessibility

FloatingPanel renders a bare <div>. Consumers must supply:

  • a role (for example "dialog", "menu", "listbox", "tooltip")
  • an accessible name via aria-label or aria-labelledby

Descendants of the rendered panel can read positioning state via useFloatingPanelContext():

tsx
const { positioned, side, align } = 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).

VariableDefaultPurpose
--ui-floating-zvar(--z-popover)z-index layer for the panel.
--ui-content-enter-duration60msRead by every directional enter token.
--ui-content-exit-duration40msRead by every directional exit token.
--ui-content-enter-from-{top|bottom|left|right}ui-content-enter-fade var(--ui-content-enter-duration) linearFull animation shorthand the panel uses when entering from that side.
--ui-content-exit-to-{top|bottom|left|right}ui-content-exit-fade var(--ui-content-exit-duration) linear forwardsFull animation shorthand the panel uses when exiting toward that side.
--ui-content-transform-originset by FloatingPanelResolved from data-side and data-align. Read it from custom keyframes that animate transform.
--ui-floating-trigger-widthset by FloatingPanel when matchTriggerWidthWidth of the trigger in pixels. Use as width, min-width, or max-width on the panel.
--floating-panel-available-heightset by FloatingPanel on every measureAvailable height before viewport overflow. Use as max-height with overflow-y: auto.
--floating-panel-available-widthset by FloatingPanel on every measureAvailable width before viewport overflow. Use as max-width with overflow-x: auto.

Override one direction with a transform-based keyframe:

css
.my-panel[data-side="bottom"] {
  --ui-content-enter-from-top:
    my-slide-down var(--ui-content-enter-duration) cubic-bezier(0.22, 1, 0.36, 1);
}

@keyframes my-slide-down {
  from {
    opacity: 0;
    transform: translateY(-6px) scale(0.98);
    transform-origin: var(--ui-content-transform-origin);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

Lower the panel below other surfaces by scoping --ui-floating-z:

css
.my-tooltip-host {
  --ui-floating-z: var(--z-dropdown);
}

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.