apx-dsv0.1Local renderer · live
K
Foundations4
  • Getting started
  • Theming
  • Templates
  • Icons
  • Avatar
  • Badge
  • DataGrid
  • Scheduler
  • Stat
  • Table
  • Timeline
  • TreeView
  • Accordion
  • Alert
  • EmptyState
  • Progress
  • Skeleton
  • Spinner
  • SplashScreen
  • Toast
  • ColorPicker
  • FileUpload
  • Form
  • Rating
  • TagsInput
  • Combobox
  • Field
  • Select
  • Toggle
  • Button
  • Calendar
  • Checkbox
  • DatePicker
  • Input
  • NumberInput
  • Radio
  • Slider
  • Switch
  • Textarea
  • AppShell
  • Div
  • Divider
  • Marquee
  • Parallax
  • Reveal
  • Section
  • SectionHeading
  • Sidebar
  • Stack
  • Surface
  • Typography
  • Image
  • Breadcrumbs
  • Carousel
  • NavigationMenu
  • Pagination
  • Stepper
  • Tabs
  • Toolbar
  • CommandPalette
  • Confirm
  • Drawer
  • HoverCard
  • Menu
  • Modal
  • Popover
  • Tooltip
  • Icon
  • Card
  • PricingCard
66 componentsapx-ds/renderer
Surface
Variant↳ other

Layout

Surface

Region primitive that establishes its own ground.

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

Loading preview…
Overview.tsx

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"

Loading preview…
BrandBand.tsx

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 wantToneThe light control on it
a dark band on a light pageinverted<Button color="neutral">
a band filled with the brand colourprimary / secondary<Button color="neutral">
a light card inside either of thosenested 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 mode on <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

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

The 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

PropDefaultEffect
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.

remappedwhy
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-default tone. 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 for inverted always, and for a brand role whose fill is saturated with a light contrast slot — the common case, and the DS default for primary and secondary. A theme that authors a pale brand fill (the default warning role is #f59e0b on #111827, and a theme is free to make primary look like that) should pass colorScheme="light", or the field gets a white caret on a yellow ground.
  • Translucency works — bg-bg-paper/10 on an inverted surface is the "glass field" treatment, and the /opacity modifier resolves correctly against DS tokens.
  • Surface needs a <ThemeProvider> above it, like every other DS component.

Examples

Overview

Loading preview…
Overview.tsx

BrandBand

Loading preview…
BrandBand.tsx

FormOnDark

Loading preview…
FormOnDark.tsx

Props

PropTypeDefaultDescription
asElementType—Render as a different element — `section`, `footer`, `aside`. Default: `'div'`. A dark band is usually a landmark, so reach for the semantic tag.
colorSchemeenum—Overrides the `color-scheme` a non-`default` tone declares. `color-scheme` is the one thing tokens cannot express: it drives the parts of a form control the page does not own — the caret, the `<select>` arrow, scrollbars, autofill — and CSS has no way to ask "is this ground dark?". `inverted` and the brand tones therefore declare `dark`, which is right for `inverted` always and for a brand role whose fill is saturated and whose `contrast` is white (the overwhelmingly common case, and the DS default for `primary` and `secondary`). A brand that authors a *pale* fill — the default `warning` role is `#f59e0b` on `#111827` ink, and a theme is free to make `primary` look like that — needs `colorScheme="light"` here, or an `<Input>` in the band gets a white caret on a yellow field. Set it when the tone's ink is dark.
styleCSSProperties—Inline style. Merged last, so it wins.
sxSx—Theme-aware inline style object. Merged after the recipe's own style.
toneenum—Ground for this region and everything inside it. Default: `'default'`.