Surfaces
An 8-step elevation ladder, plus a context-aware Surface Provider so every elevated primitive renders relative to its substrate — not against a hard-coded level that collapses the moment it nests.
Elevation ladder
Light theme keeps two color steps and differentiates levels 3–8 with stacked shadow drops.
Dark theme walks the full luminance range from oklch(0.08) to oklch(0.32), layered with inset highlights and outer hairlines. Every level pairs
a background with a shadow recipe — never use one without the other.
<!-- Each step pairs a luminance level with its shadow recipe -->
{#each [1, 2, 3, 4, 5, 6, 7, 8] as level}
<div class={[
`bg-surface-${level} shadow-surface-${level}`,
level === 1 && 'ring-1 ring-border/50 dark:ring-0'
]}>
Level {level}
</div>
{/each}Why relative elevation
Hard-coded surface levels collapse the moment a component nests inside something at the same
level. Inside a surface-5 dialog, a popover hard-coded to bg-surface-5 literally disappears into the dialog. bloom-nx solves this with a
Svelte context: components read their substrate from the nearest provider,
lift by a per-primitive offset, then re-provide the new level so further nesting
keeps lifting.
Conventional offsets
Two strategies set surface levels: relative (read substrate, add offset) and absolute (pin a level regardless of context).
| Primitive | Strategy | Resolves to |
|---|---|---|
| Card, Sidebar, Toolbar, Alert, Dock | absolute | surface-3 (Card accepts 3 | 4 | 5) |
| Popover, Dropdown, Context-Menu, Hover-Card, Select, Menubar, Navigation-Menu | relative (offset +2) | page → surface-3, card → surface-5, dialog → surface-7 |
| Dialog, Sheet, Drawer, AlertDialog | relative (offset +4) | page → surface-5, card → surface-7, feed (2) → surface-6 |
App shells: prototype layouts declare data-substrate="1" (page).
Feed columns use <Substrate level={2}> — sunken band on the page; cards inside
feed stay at surface-3.
Relative elevation, in practice
A dropdown opened over the page renders at surface-3 (page (1) + 2 = 3). Open that same dropdown inside a dialog (surface-5) and it shifts to surface-7. The shadow recipe and background color scale with
the resolved level so depth reads accurately against its immediate context.
Page (substrate 1)
Card (substrate + 2 → surface 3)
Dialog (substrate + 2 → surface 5)
Popover (substrate + 2 → surface 7)
<script lang="ts">
import { Elevated } from '$lib/components/ui/surface';
</script>
<!-- Page (substrate 1) -->
<Elevated offset={2} padded>
<!-- → surface-3 -->
<Elevated offset={2} padded>
<!-- → surface-5 -->
<Elevated offset={2} padded>
<!-- → surface-7 -->
</Elevated>
</Elevated>
</Elevated>Elevated primitive
<Elevated> wraps the read-substrate / compute-level / apply-classes /
re-provide-context pattern in one component. Pass offset for relative lift or level for an absolute pin. Optional shadowLevel keeps the shadow signature
constant when only the background should track substrate.
offset 2 → surface 5
on substrate 3
level 5 → surface 5
absolute pin
bg 5, shadow 3
fixed shadow signature
<!-- Sugar over the hook. `offset` reads substrate and lifts; `level` pins absolute. -->
<Elevated offset={2}>…</Elevated>
<Elevated level={5}>…</Elevated>
<!-- `shadowLevel` lets the shadow signature stay fixed while bg tracks substrate. -->
<Elevated offset={2} shadowLevel={3}>…</Elevated>Authoring an elevated primitive
Reach for <Elevated> first. Drop to the hook directly only when authoring a
new primitive (e.g. wrapping a bits-ui content node). Always advertise the resolved level back
to descendants via data-substrate={level} — a literal value (e.g. data-substrate="7") lies to children when the resolved level differs.
useSurfaceProvider(2) resolved against substrate 3 — renders at surface-5.
<script lang="ts">
import {
setSurfaceContext,
getSurfaceLevel,
useSurfaceProvider,
useSurfaceAbsolute,
surfaceClass
} from '$lib/components/ui/surface';
// At a Popover / Dropdown content root: read the substrate, add the
// per-primitive offset, re-provide for descendants, render with the
// resulting level.
const level = useSurfaceProvider(2); // popover convention = +2
</script>
<div
class={surfaceClass[level]}
data-substrate={level}
>
{@render children?.()}
</div>Level catalog
| Level | Role | Used by |
|---|---|---|
| surface-1 | Page background | app shell main background |
| surface-2 | Sunken / muted | tracks, fills, in-range bands |
| surface-3 | Base elevated | cards, sidebars, alerts, popovers on page |
| surface-4 | Raised card | nested cards, avatar discs above card |
| surface-5 | Modal overlay | dialogs, sheets, popovers over cards |
| surface-6 | Raised modal | nested elements over dialogs |
| surface-7 | Deep floating | popovers / menus over dialogs |
| surface-8 | Maximum cap | extreme nesting limit (clamped) |
Card-on-card
A nested <Card.Root level={4}> sits on top of its parent card without the
hierarchy collapsing. Pair this with the deliberately raised offset whenever a card contains another
card.
Outer card
Default level 3 — absolute elevation pin.
Nested card
Level 4 — visibly raised against its parent.
<script lang="ts">
import {
setSurfaceContext,
getSurfaceLevel,
useSurfaceProvider,
useSurfaceAbsolute,
surfaceClass
} from '$lib/components/ui/surface';
// At a Popover / Dropdown content root: read the substrate, add the
// per-primitive offset, re-provide for descendants, render with the
// resulting level.
const level = useSurfaceProvider(2); // popover convention = +2
</script>
<div
class={surfaceClass[level]}
data-substrate={level}
>
{@render children?.()}
</div>API reference
Helpers exported from $lib/components/ui/surface.
| Prop | Type | Default | Description |
|---|---|---|---|
() => 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 1 | Read the current substrate level. Returns 1 when no provider is set. | |
(level: 1..8) => SurfaceLevel | — | Pin an absolute level for descendants. Used by Card to seed context. | |
(offset: number) => SurfaceLevel | — | Read substrate, add `offset`, clamp 1..8, re-provide. Conventional offsets: 2 for popover-family primitives. | |
(level: 1..8) => SurfaceLevel | — | Pin a level absolutely, ignore parent. `5` is the bloom-nx modal convention. | |
Record<SurfaceLevel, string> | — | Tailwind-safe class literal map. Use this instead of templating `bg-surface-${level}` — the latter does not survive content extraction. | |
(bg: 1..8, shadow?: 1..8) => string | — | Same as `surfaceClass[level]` but lets the shadow level differ from the bg level — e.g. keep `shadow-surface-3` while bg tracks substrate. | |
{ offset?: number; level?: 1..8; shadowLevel?: 1..8; padded?: boolean } | — | Sugar component that wraps the hook. Provide either `offset` (relative) or `level` (absolute). `padded` adds default `p-4`. |