Surface
A region that establishes its own ground — the background/foreground/border tokens everything inside it resolves against.
Every DS component reads text-fg, bg-bg*, border-*. Those all assume a light ground, so a
dark section used to mean hand-written token values at each call site: body copy in near-black on
a dark band, no readable <Input> variant, and no way to ask for a light button. <Surface>
fixes that once at the token layer, instead of growing an onDark axis on every component that
might one day sit on a dark band.
Overview — an inverted band
There are two kinds of band, and Surface has a tone for each. tone="inverted" grounds on the
page's own ink — the dark section. tone="primary" / tone="secondary" ground on a brand
role — the CTA band, the newsletter strip, the pricing highlight.
A brand band — tone="primary"
When to use
- A dark hero, feature band, newsletter capture, or footer inside an otherwise light page.
- A band filled with the brand colour — a closing CTA, a signup strip.
- A light card sitting inside either kind of band (nest a second
Surface— see below). - Any region that should own its background rather than inherit the page's.
Which tone
| You want | Tone | The light control on it |
|---|---|---|
| a dark band on a light page | inverted | <Button color="neutral"> |
| a band filled with the brand colour | primary / secondary | <Button color="neutral"> |
| a light card inside either of those | nested inverted | <Button color="primary"> |
In all three the thing inside is a plain DS component with no on-dark prop. That is the point: the tone changes what the tokens resolve to, so the call site stays colour-free.
When NOT to use
- You want the whole app dark → that's
modeon<ThemeProvider>, a user preference. - You want a different brand palette for a region → that's
<ThemeProvider scope>. - You just want a coloured box →
<Div bg="…">or a<Card>. Surface is about what its descendants resolve to, not about painting one rectangle.
Anatomy
<div style="display:contents; --sds-surface-ink; --sds-surface-paper"> ← capture (inverted only)
└── <div|section|footer class="bg-bg text-fg" style="--sds-palette-*"> ← the surface<div style="display:contents; --sds-surface-ink; --sds-surface-paper"> ← capture (inverted only)
└── <div|section|footer class="bg-bg text-fg" style="--sds-palette-*"> ← the surfaceThe capture element exists for one reason. Swapping the tokens means writing
--sds-palette-background-default: var(--sds-palette-foreground-default) and the reverse — on a
single element that is a custom-property cycle, and CSS resolves a cycle by discarding every
property in it. The surface would render completely unstyled, with no error. So the outer element
freezes the inherited pair under private names and the inner one reads those; inheritance is
resolved before a child's own declarations apply, so there is no cycle. It is display: contents,
so it adds no box and cannot disturb a flex or grid parent.
The brand tones do not need it. They have no swap: they point the surface roles at
palette.<role>.main / .contrast, and they never write to the brand role itself, so nothing
references anything being redefined. tone="primary" is therefore a single element. There is a
test that asserts this directly — that no brand tone declares a variable it also reads — because
if that ever stopped being true the band would silently render unstyled.
Props
| Prop | Default | Effect |
|---|---|---|
tone | 'default' | 'inverted' swaps the ground; 'primary' / 'secondary' fill it with that brand role. 'default' still paints bg-bg text-fg. |
as | 'div' | Render a semantic element — a band is usually a landmark. |
colorScheme | — | Overrides the color-scheme a non-default tone declares. Set "light" when a brand's fill is pale and its contrast is dark — see Notes. |
sx / style / className | — | Merged after the tone map, so a caller can override one token. |
What the tone remaps
Same table for both kinds of tone — inverted swaps the surface roles with each other, the brand
tones point them at palette.<role>.main / .contrast.
| remapped | why | |
|---|---|---|
background-*, foreground-*, border-* | ✅ | the surface tokens — this is the whole point |
neutral role | ✅ | neutral is the DS's greyscale/chrome role, not a brand colour. This is what makes <Button color="neutral"> the light button with dark text a dark band wants. |
primary / secondary / success / warning / danger / info | ❌ | a brand or status colour has to stay recognisable. A danger button that goes pale on a dark band is a bug. Their contrast slots already sit on their own fill. A brand tone does not remap itself either — that is what makes it cycle-free. |
--sds-focus-ring | ✅ | the default ring is tuned for a light ground and can vanish on a dark one — leaving it would be an accessibility regression handed out by a styling feature. |
Every value is a color-mix() of the inherited palette, never a literal colour. So a Surface
inside a branded <ThemeProvider scope> inverts that brand, and inverting a dark theme gives you
a light band on a dark page — which is why the prop is tone="inverted" and not tone="dark". The
brand tones read the scoped primary / secondary roles for the same reason.
A brand tone has no muted step, on purpose
A brand fill is a mid-tone; the page ink inverted grounds on is near-black. Measured across the
six brand fills the shipped templates actually use, plus the DS defaults, a saturated fill has
about 6:1 of contrast at full ink — that is the entire budget, where inverted starts from
~16:1.
So foreground.muted and foreground.subtle both resolve to a 90% blend on a brand tone, which
is barely distinguishable from full ink. Two names, one value. That is not a rough edge to smooth
later: on a saturated band the readable design has no muted hierarchy, and a template that wants
one needs a darker main. Anything below 90% drops body copy under 4.5:1 on three of the six real
fills — including the violet CTA band this feature was built for.
Borders are measured separately, against WCAG 1.4.11's 3:1 for a UI boundary rather than 4.5:1.
border-default and border-strong sit much closer to the ink than the foreground steps do;
border-subtle is a decorative hairline and is deliberately below 3:1, because 1.4.11 does not
apply to it and pushing it there would turn every divider into a rule.
Nesting
Two inversions cancel — an inner Surface captures the outer's already-inverted palette and
swaps it back, so a light card inside a dark band needs no special case.
The same mechanism gives you the light card inside a brand band, for free: nest
tone="inverted" inside tone="primary" and it captures the band's current ink and ground —
which the brand tone has just re-pointed at contrast and main — and swaps them. White ground,
brand ink. No third tone.
Notes
[color-scheme:dark]rides along on every non-defaulttone. That one isn't a token: the caret,<select>arrow, scrollbars and autofill are drawn by the browser, and without it a text input in a dark band gets a black caret on a black field. It is right forinvertedalways, and for a brand role whose fill is saturated with a lightcontrastslot — the common case, and the DS default forprimaryandsecondary. A theme that authors a pale brand fill (the defaultwarningrole is#f59e0bon#111827, and a theme is free to makeprimarylook like that) should passcolorScheme="light", or the field gets a white caret on a yellow ground.- Translucency works —
bg-bg-paper/10on an inverted surface is the "glass field" treatment, and the/opacitymodifier resolves correctly against DS tokens. - Surface needs a
<ThemeProvider>above it, like every other DS component.