Surfaces

How Cubby keeps floating UI (popovers, dropdowns, dialogs, sheets) clearly lifted above whatever is behind it, at any nesting depth, in light and dark mode.

Open a dropdown and it should read as floating above the page. Open a popover inside a dialog and it should still read as floating above the dialog. Surfaces is the system that makes that happen automatically, at any depth. Most of the time you never touch it.

How elevation works

Every floating component (Popover, Dropdown, Dialog, Sheet, Drawer, and the rest) sits on a level from 1 to 8. The deeper something is nested, the higher its level. A card sits low, a dialog higher, a popover opened inside that dialog higher still. Each step up gets a lighter surface and a stronger shadow, so every layer reads as lifted above the one behind it.

Page
Card
Popover
Menu

There are eight levels because a nested case can go surprisingly deep, and the ladder should never run out. Most apps only ever reach for three or four.

One thing is worth knowing up front, because light and dark behave differently:

  • Light mode keeps levels 3 and up pure white. The shadow alone does the lifting.
  • Dark mode brightens the surface at each level, from oklch(0.205) at level 1 to oklch(0.402) at level 8, so each layer is visibly lighter than the one below.

The two knobs

Every elevated component takes two props:

  • level picks the surface color: which rung of the 1 to 8 ladder it sits on.
  • shadowLevel picks the shadow weight: how heavily it casts.

They move independently. A wide dialog can sit on a mid surface but throw a heavy shadow; a small popover can sit higher yet cast a lighter one. Drag both and watch what changes:

Menu
Profile
Settings
Sign out
Surfacelevel
ShadowshadowLevel
solidSurface(5, 5)

In light mode the surface stops changing above level 3 (they are all white) while the shadow keeps growing. In dark mode the surface color climbs with every level. That split is the whole idea: light mode leans on shadow, dark mode leans on color. Toggle the site theme to compare.

Using it

Here is the part that matters most: you rarely set either prop. The defaults are tuned for the common case. A popover already sits at the right level; a dialog already sits higher.

The one time you reach for level is when you nest deeper than usual, like opening a popover from inside a dialog:

Bump level (not shadowLevel) when you nest. shadowLevel stays pinned per component, so a popover keeps reading like a popover no matter how deep it goes.

That is the whole API for most people. Everything below is reference: the raw tokens, four helpers for building your own surfaces, and the knobs for retuning the system. Skip it until you need it.

Reference

The ladder

Eight surface colors, drawn here in your current theme:

1
2
3
4
5
6
7
8
LevelLightDarkTypical use
surface-1oklch(0.97 0 0)oklch(0.205 ◇ ◆)Page background
surface-2oklch(0.985 0 0)oklch(0.235 ◇ ◆)Inline toolbars, menubars
surface-3oklch(1 0 0)oklch(0.264 ◇ ◆)Cards, popovers, dropdowns, tooltips
surface-4oklch(1 0 0)oklch(0.293 ◇ ◆)Tabs indicator, transitional
surface-5oklch(1 0 0)oklch(0.321 ◇ ◆)Dialogs, sheets, drawers, sub-menus
surface-6oklch(1 0 0)oklch(0.348 ◇ ◆)Transitional
surface-7oklch(1 0 0)oklch(0.375 ◇ ◆)Popovers nested inside dialogs
surface-8oklch(1 0 0)oklch(0.402 ◇ ◆)Maximum elevation (rare)

is var(--neutral-chroma) and is var(--neutral-hue); both come from the brand tint.

The named tokens you use elsewhere alias onto the ladder, so tuning the ladder updates all of them:

Shadows

shadowLevel tracks the visual weight of the element, not its surface level. Pick it by size:

  • Under ~400px wide: 3
  • ~400 to 800px: 5
  • Over ~800px or fullscreen: 7
  • Never let shadowLevel exceed level by more than 2.
shadowLevelDrop layersFeel
10 (just a 1px ring)Barely there
21Tooltip, quiet
32Dropdown, popover, card
43Toast, floating elements with no trigger
54Dialog, sheet
65Transitional
76Dramatic (hero modals)
87Maximum gravity (rare)

In dark mode the recipe also adds an inset highlight and ring, giving surfaces a lit-from-above edge. In light mode those insets are no-ops and the drop shadow does everything.

Set the weight through the shadowLevel prop (<PopoverContent shadowLevel={5} />) or as the second argument to any helper below (solidSurface(3, 5) is a level-3 surface carrying a level-5 shadow), rather than reaching for a shadow utility class.

That last part is not a style preference. A shadow applied by theme key or by a bracketed arbitrary value lands in tailwind-merge's shadow-color group rather than its shadow group, so a later shadow-none from a consumer never wins and stylesheet order silently decides. The helpers use the one form tailwind-merge treats as a shadow, which is why passing a level through them stays overridable.

Component defaults

You do not need these day to day; they are what each component already uses. Reach in only to match a custom element to a built-in one.

State overlays

Hover and selected backgrounds are translucent overlays, so they lift whatever surface they sit on by a fixed perceptual step, whatever its level:

Because they are overlays and not fixed colors, hover on a surface-3 row brightens by the same amount as on a surface-7 row. Hover always reads as hover, even deeply nested. The values (6% for hover, 10% for selected) are matched to fluid-functionalism.

Building your own surface

Making a custom floating container? Four helpers in @/registry/default/lib/elevated return Tailwind class strings. Drop them on any element you want elevated. Pick by situation:

HelperWhen
solidSurfaceMost cases. The default choice.
elevatedSurfaceSticky or opaque children sit near the edges (labels, inset footers).
surfaceClassesThe surface already defines its own edge (a real CSS border).
flushSurfaceA sheet or drawer pinned against a screen edge.

solidSurface(level, shadowLevel?)

Returns bg-surface-N, the combined drop and rim shadow, and exposes --popup-surface for descendants. Use it for any floating container without sticky or opaque children near its edges.

elevatedSurface(level, shadowLevel?)

Same look as solidSurface, but it paints the rim on an ::after layer above the content instead of in the box-shadow. Reach for it when sticky or opaque children sit near the edges: with solidSurface the rim lives in the box-shadow, so an opaque child at an edge renders on top and hides it. The two common cases are sticky group labels (Select, Combobox, Autocomplete) and opaque inset footers (Dialog, Sheet, Drawer, where a bg-muted footer would cover the bottom rim).

The host must be positioned and have a border-radius so the rim clips correctly.

surfaceClasses(level, shadowLevel?)

The primitive the others build on: just bg-surface-N and the downward drop shadow, no rim. Reach for it directly when the surface defines its own edge some other way (a real CSS border, a single-edge rim).

flushSurface(level, innerEdge)

For viewport-flush containers: the default Sheet and Drawer variants that slide in pinned against a screen edge. Three of their four edges sit at the viewport boundary, so solidSurface's always-downward shadow and four-edge rim point the wrong way. flushSurface instead casts the drop shadow toward the inner, content-facing edge, and paints a single-edge rim on that same edge. Pass the content-facing edge directly, or derive it from the attach side with INNER_EDGE_FROM_ATTACH_SIDE (right → left, bottom → top, and so on).

The chrome surface

One surface sits deliberately off the ladder. --chrome is a near-black fill in both themes, for persistent app chrome (a header bar, a command bar) and for floating labels that should read as instrument rather than page. TooltipContent exposes it as variant="chrome".

It is not an inverse surface. Inverting in dark would put a white slab at roughly 116 times the page's luminance into chrome you cannot scroll away from, so both themes stay dark. In light the fill borrows --neutral, the same black as a solid neutral Button. In dark it steps below the page rather than above it, because up is where every card lives and a lifted near-black slab reads as one more card.

Put data-surface="chrome" on the container and use ordinary page classes inside. That attribute re-points nine tokens for the whole subtree: --foreground, --muted-foreground, --border, --muted, --accent and --accent-foreground, --input, and the hover and active washes. Anything painting from those needs no per-control classes:

The edge is opt-in

CHROME_SURFACE paints the fill and nothing else, the same way SURFACE_BG does for a ladder rung. The edge is a separate utility you add when the surface needs one:

Add it for anything large: a header bar, a command bar, a chrome Select or dropdown. Leave it off for anything small, the way the chrome Tooltip does, because an edge on a two-line label is more boundary than there is object and a tooltip already carries its shape through its text and arrow.

In dark, --chrome-shadow is not decoration. The chrome fill measures 1.08:1 against the dark page, so on a large surface that token is the only thing separating the two. In light the same pair measures 16.3:1, the step alone is the boundary, and the token collapses to the ladder's ring-without-drop rung, which keeps the surface flush rather than raised.

The edge keeps a chrome surface flush, not raised: there is no drop layer in it, so a header bar sits in the page rather than above it. A chrome surface that genuinely floats needs its own sibling token rather than a per-element override, because a custom property's var()s are substituted where the property is declared, not where it is used.

--primary is left alone on purpose, so a primary Button keeps its brand fill anywhere. bg-chrome is the only chrome utility, for the fill itself. There is deliberately no bg-chrome-hover beside it: a second idiom for the same job is what the attribute exists to remove.

Two things to know:

  • Portalled content does not inherit it. Menus, dialogs and tooltips render at the document body, so a floating component that wants this look has to carry the attribute itself. That is exactly what TooltipContent variant="chrome" does.
  • The edge is not the ladder's. --surface-rim-* and the ring baked into --surface-shadow-* both assume a fill lighter than the page. This fill is darker, so the ladder's ring lands lighter than the very fill it should define and the boundary blurs. --chrome-shadow replaces it, carrying the outer boundary and the top-edge specular in one value. It is a box-shadow value, not a color: writing a bare color into it voids the entire box-shadow, because a single invalid layer invalidates the whole declaration.

Because chrome is off the ladder, level and shadowLevel do not apply to either helper.

Customizing the system

Brand tint

Two variables at the top of :root tint every neutral in the system, so you can retune the whole thing from one place:

Change --neutral-hue to rotate the entire palette without touching individual tokens:

  • 0 to 90: warm (red, orange, amber, yellow). Try ~70 for amber.
  • 90 to 180: green.
  • 180 to 270: cool (cyan, blue). The primary lives at 250.
  • 270 to 330: cool purple and violet. The neutral tint sits at 275.
  • 330 to 360: pink and magenta, heading back toward warm.

--neutral-chroma around 0.005 to 0.01 is the visibility floor; we sit just under at 0.004 for a quieter feel.

Tracking the level from descendants

When you set level, the helpers expose --popup-surface on that element. Children can match the surface color without hardcoding a level:

The var(--popover) fallback keeps things working if the variable is not set in some context.

Design decisions

Surfaces is inspired by fluidfunctionalism.com/elevated, adapted to a prop-based API instead of a context, with a few deliberate divergences from a strict "tint everything on the ladder" reading.

Token reference

Every token lives on :root and is exposed as a Tailwind utility through @theme inline. Tune any of them in your globals.css to customize the system without touching the components.