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.
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 tooklch(0.402)at level 8, so each layer is visibly lighter than the one below.
The two knobs
Every elevated component takes two props:
levelpicks the surface color: which rung of the 1 to 8 ladder it sits on.shadowLevelpicks 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:
levelshadowLevelsolidSurface(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:
| Level | Light | Dark | Typical use |
|---|---|---|---|
surface-1 | oklch(0.97 0 0) | oklch(0.205 ◇ ◆) | Page background |
surface-2 | oklch(0.985 0 0) | oklch(0.235 ◇ ◆) | Inline toolbars, menubars |
surface-3 | oklch(1 0 0) | oklch(0.264 ◇ ◆) | Cards, popovers, dropdowns, tooltips |
surface-4 | oklch(1 0 0) | oklch(0.293 ◇ ◆) | Tabs indicator, transitional |
surface-5 | oklch(1 0 0) | oklch(0.321 ◇ ◆) | Dialogs, sheets, drawers, sub-menus |
surface-6 | oklch(1 0 0) | oklch(0.348 ◇ ◆) | Transitional |
surface-7 | oklch(1 0 0) | oklch(0.375 ◇ ◆) | Popovers nested inside dialogs |
surface-8 | oklch(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
shadowLevelexceedlevelby more than 2.
| Shadow | Drop layers | Feel |
|---|---|---|
shadow-surface-1 | 0 (just a 1px ring) | Barely there |
shadow-surface-2 | 1 | Tooltip, quiet |
shadow-surface-3 | 2 | Dropdown, popover, card |
shadow-surface-4 | 3 | Toast, floating elements with no trigger |
shadow-surface-5 | 4 | Dialog, sheet |
shadow-surface-6 | 5 | Transitional |
shadow-surface-7 | 6 | Dramatic (hero modals) |
shadow-surface-8 | 7 | Maximum 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.
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:
| Helper | When |
|---|---|
solidSurface | Most cases. The default choice. |
elevatedSurface | Sticky or opaque children sit near the edges (labels, inset footers). |
surfaceClasses | The surface already defines its own edge (a real CSS border). |
flushSurface | A 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).
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.