Floating Surface
The shared floating-overlay module — one Portal/Positioner/Popup composer, the popup surface recipes, the list-item recipe, and the in-panel search row.
- Status
- Since
0.7.0- Accessibility pattern
- Base UI popup primitives
Last updated
Install
Add Floating Surface from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/floating-surfaceUsage
floating-surface is the answer to a question every overlay used to answer separately: what does a floating panel look like, how does it enter and leave, how does a row inside it highlight, and what does a search field inside a panel look like.
It is a module of recipes and one composer, not a rendered component you place in a page:
FloatingSurfacecomposes a Base UI namespace'sPortal → Positioner → Popup (→ Viewport)into a single element, paints one of four surfaces, applies the enter/exit motion, and carries the internal theme scope across the portal boundary.FloatingArrowdraws the directional wedge in either the bordered panel tone or the inverted tooltip tone.menuItemVariantsis the one row recipe behind menu items, checkbox/radio items, submenu triggers, select options, combobox options and command rows.createMenuParts(prefix)binds the one menu-row implementation to a component'sdata-slotprefix. Base UI'sContextMenunamespace re-exportsMenu's item, checkbox-item, radio-item, group-label, submenu-trigger and separator parts verbatim, soDropdownMenuandContextMenuare not two components that look alike — below the trigger they are the same component.PanelSearchFrame/PanelSearchInputare the "search inside a panel" row: a leading glyph, no box of its own, and a hairline below.
<FloatingSurface
parts={{
Portal: Popover.Portal,
Positioner: Popover.Positioner,
Popup: Popover.Popup,
}}
slot="popover"
surface="panel"
positioning={{ side, sideOffset, align, collisionPadding }}
popupProps={props}
>
{children}
</FloatingSurface>parts stays structural on purpose: each overlay hands in its OWN Base UI parts, so PopoverContent, SelectContent and friends keep their precise Base UI prop types. Only the internal hand-off is generic.
Anatomy
Floating Surface is a compound component. Every exported part, with the
data-slot it renders (generated from the canonical source):
Examples
The surfaces
surface picks what the popup paints. panel is the 16px popover tier (Popover, HoverCard); menu is the same surface at list density with a 4px inset floor (DropdownMenu, ContextMenu, Select, Combobox, Command); tooltip is the inverted ink chip; navigation is the morphing mega-menu panel.
Motion
motion picks the timing. Every floating surface enters and leaves at duration-fast (150ms) with ease-standard; only NavigationMenu takes base (200ms), because it resizes between items rather than simply appearing. Modal surfaces — Dialog, AlertDialog, Sheet — sit outside this module and move at base with their backdrops.
Rows
menuItemVariants carries the surface ladder: data-highlighted (Base UI sets it for both pointer hover and keyboard navigation) washes the row to surface-2, and pressed or selected takes surface-3. The md radius inside the list's 4px padding keeps every wash inset from the popup hairline and concentric with the popup's lg corner.
<Menu.Item className={cn(menuItemVariants({ tone: "destructive" }))} />
<Select.Item className={cn(menuItemVariants({ indicator: "trailing" }))} />Panel search
A bordered Input inside a bordered popup nests two borders. PanelSearchFrame is the alternative: a sticky, full-bleed header row with a hairline below.
<PanelSearchFrame focusTint={false}>
<Combobox.Input className={panelSearchInputClassName} />
</PanelSearchFrame>Turn focusTint off when the field is autofocused as the panel opens — a permanently-lit hairline reads as a stray border rather than a focus affordance.
API Reference
FloatingSurface
| Prop | Type | Default | Description |
|---|---|---|---|
parts* | FloatingSurfaceParts | — | The Base UI namespace parts this surface renders. |
slot* | string | — | The component's data-slot prefix — "popover" yields popover-positioner,
popover-content and popover-viewport. |
arrow | React.ReactNode | — | An arrow element rendered as the popup's first child. |
children | React.ReactNode | — | The popup's content. |
className | string | — | Extra classes for the popup, appended after the recipe. |
motion | "base" | "fast" | — | |
popupProps | FloatingPartProps | — | Props forwarded to the Popup — this is where the consumer's own props land. |
popupSlot | string | `${slot}-content` | Overrides the popup's data-slot when the component's popup is not called <slot>-content
(NavigationMenu's is navigation-menu-popup). |
portalProps | object | — | Props forwarded to the Portal. |
positionerProps | FloatingPartProps | — | Extra props forwarded to the Positioner, merged after positioning. |
positioning | object | — | Positioning props forwarded to the Positioner (side, align, sideOffset, …). |
surface | "menu" | "navigation" | "panel" | "tooltip" | — | |
viewport | "always" | "auto" | "auto" | When to render the Viewport. "auto" renders it only when viewportProps is supplied and
wraps children in it; "always" renders an empty Viewport as the popup's only child (the
NavigationMenu shape, where Base UI projects each item's content into it). |
viewportProps | FloatingPartProps | — | Props forwarded to the Viewport. |
Data attributes and CSS variables on FloatingSurface
| Attribute | Values |
|---|---|
data-slot | mirrors a prop or state value |
FloatingSurfaceParts
| Prop | Type | Default | Description |
|---|---|---|---|
Popup* | React.ElementType<any, keyof React.JSX.IntrinsicElements> | — | The namespace's Popup — the painted surface. |
Portal* | React.ElementType<any, keyof React.JSX.IntrinsicElements> | — | The namespace's Portal — mounts the surface outside the trigger's DOM subtree. |
Positioner* | React.ElementType<any, keyof React.JSX.IntrinsicElements> | — | The namespace's Positioner — owns side/align/offset/collision. |
Viewport | React.ElementType<any, keyof React.JSX.IntrinsicElements> | — | The namespace's Viewport, when it has one. |
FloatingArrow
| Prop | Type | Default | Description |
|---|---|---|---|
element* | React.ElementType<any, keyof React.JSX.IntrinsicElements> | — | The namespace's Arrow part. Named element rather than part because React's DOM typings
already claim part (CSS Shadow Parts) on every element the arrow can render. |
slot* | string | — | The arrow's data-slot. |
className | string | ((state: never) => string | undefined) | — | Extra classes for the arrow wrapper — Base UI's (state) => string form included. |
tone | "panel" | "tooltip" | "panel" | panel draws the bordered popover wedge; tooltip the smaller inverted-ink one. |
Data attributes and CSS variables on FloatingArrow
| Attribute | Values |
|---|---|
data-slot | mirrors a prop or state value |
PanelSearchFrame
| Prop | Type | Default | Description |
|---|---|---|---|
focusTint | boolean | true | Tint the hairline while the field inside has focus. Turn it OFF when the field is autofocused on open (a permanently-lit hairline reads as a stray border, not a focus affordance). |
size | "lg" | "md" | "md" | Row height tier. md is the in-panel default; lg is the palette-in-a-dialog tier, where the
search row is the dialog's primary affordance. |
Data attributes and CSS variables on PanelSearchFrame
| Attribute | Values |
|---|---|
data-slot | "panel-search" |
Menu parts
createMenuParts(prefix) returns Group, RadioGroup, Item, CheckboxItem, RadioItem, Label, Separator, Shortcut and SubTrigger, each already carrying <prefix>-<part> as its data-slot.
| Prop | Type | Default | Description |
|---|---|---|---|
inset | boolean | false | Adds inline-start padding so the label aligns with rows that carry a leading indicator. |
tone | MenuItemTone | "default" | destructive tints the row for delete/remove actions. |
Accessibility
- The module adds no ARIA of its own — every part it renders is a Base UI part, and Base UI owns the roles,
aria-*wiring and focus management for each namespace. - Popups keep their native focus outline. A floating popup can receive keyboard focus (initial focus, focus wrap), so the centralized
:focus-visibleoutline stays as its indicator (WCAG 2.4.7). menuItemVariantsnever signals state by colour alone: a highlighted row changes background, and selection is additionally carried by the component's own check or dot indicator.- Rows are washed on
data-highlighted, which Base UI sets for keyboard navigation as well as pointer hover, so keyboard users see the same affordance as mouse users. PanelSearchFramerenders only chrome — the field inside it keeps its own accessible name (aria-labelor a wired label).
| Contract | States tested |
|---|---|
| Behaviour | default, open, highlighted, selected, disabled |
| Accessibility | focus-visible, labeled, semantic-html |
| Visual | default, hover, pressed, selected, disabled |