Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

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
stable
Since
0.7.0
Accessibility pattern
Base UI popup primitives

Last updated

Panel surface

The 16px popover tier — Popover and HoverCard.

Menu surface
Open⌘O
Highlighted row
Delete
Result row

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-surface

Usage

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:

  • FloatingSurface composes a Base UI namespace's Portal → 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.
  • FloatingArrow draws the directional wedge in either the bordered panel tone or the inverted tooltip tone.
  • menuItemVariants is 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's data-slot prefix. Base UI's ContextMenu namespace re-exports Menu's item, checkbox-item, radio-item, group-label, submenu-trigger and separator parts verbatim, so DropdownMenu and ContextMenu are not two components that look alike — below the trigger they are the same component.
  • PanelSearchFrame / PanelSearchInput are 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):

FloatingArrow
FloatingSurface
PanelSearchFrame — data-slot="panel-search"
PanelSearchInput

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.

Panel surface

The 16px popover tier — Popover and HoverCard.

Menu surface
Open⌘O
Highlighted row
Delete
Result row

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" }))} />

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

PropTypeDefaultDescription
parts*FloatingSurfacePartsThe Base UI namespace parts this surface renders.
slot*stringThe component's data-slot prefix — "popover" yields popover-positioner, popover-content and popover-viewport.
arrowReact.ReactNodeAn arrow element rendered as the popup's first child.
childrenReact.ReactNodeThe popup's content.
classNamestringExtra classes for the popup, appended after the recipe.
motion"base" | "fast"
popupPropsFloatingPartPropsProps forwarded to the Popup — this is where the consumer's own props land.
popupSlotstring`${slot}-content`Overrides the popup's data-slot when the component's popup is not called <slot>-content (NavigationMenu's is navigation-menu-popup).
portalPropsobjectProps forwarded to the Portal.
positionerPropsFloatingPartPropsExtra props forwarded to the Positioner, merged after positioning.
positioningobjectPositioning 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).
viewportPropsFloatingPartPropsProps forwarded to the Viewport.

Data attributes and CSS variables on FloatingSurface

AttributeValues
data-slotmirrors a prop or state value

FloatingSurfaceParts

PropTypeDefaultDescription
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.
ViewportReact.ElementType<any, keyof React.JSX.IntrinsicElements>The namespace's Viewport, when it has one.

FloatingArrow

PropTypeDefaultDescription
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*stringThe arrow's data-slot.
classNamestring | ((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

AttributeValues
data-slotmirrors a prop or state value

PanelSearchFrame

PropTypeDefaultDescription
focusTintbooleantrueTint 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

AttributeValues
data-slot"panel-search"

createMenuParts(prefix) returns Group, RadioGroup, Item, CheckboxItem, RadioItem, Label, Separator, Shortcut and SubTrigger, each already carrying <prefix>-<part> as its data-slot.

PropTypeDefaultDescription
insetbooleanfalseAdds inline-start padding so the label aligns with rows that carry a leading indicator.
toneMenuItemTone"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-visible outline stays as its indicator (WCAG 2.4.7).
  • menuItemVariants never 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.
  • PanelSearchFrame renders only chrome — the field inside it keeps its own accessible name (aria-label or a wired label).
ContractStates tested
Behaviourdefault, open, highlighted, selected, disabled
Accessibilityfocus-visible, labeled, semantic-html
Visualdefault, hover, pressed, selected, disabled

Do / Don't

Do
Compose FloatingSurface and menuItemVariants when you build a new anchored overlay or list surface, so it inherits the system's surface, motion, and highlight ladder for free.
Don't
Restate the Portal/Positioner/Popup plumbing, a row's hover wash, or a panel search row in a component. That is exactly the duplication this module replaced — and it drifts.

On this page