Surface
Declares an "on-color" tone for a colored or dark region so every nested button-family component automatically switches to high-contrast, surface-adaptive styling — set the surface once, no prop-drilling.
pnpm add @tessinaui/uiWhen to use
- A coloured or dark region that holds buttons. A hero, a promo band or an inverted card: wrap it once and every nested
Button,IconButton,Fab,SplitButton,ToggleButtonandButtonGroupswitches to on-color styling. - A fill with a paired ink.
backgroundpaints the fill and its matchingon-*ink together, so the text is legible in both themes. - A plate you paint yourself. A photo, a gradient or a brand colour: pair it with
ink="light"orink="dark"so the solid button has something to contrast against. - A region that sets the tone for its subtree. Nested components read the declared tone, so nothing is drilled through props.
- A rounded container for any of the above.
roundedsets the corner radius on the surface itself.
When not to use
- For a neutral card or panel. There is no
background="card"or"muted"; those are default-tone regions. Use a Card, or paint them withclassNameand leave the tone alone. - For spacing, elevation or borders. Surface owns colour and radius. Padding is the caller's:
className="p-6"is not a gap in the API. - For stacking or arranging children. It does not lay anything out. Use Stack or Container inside it.
- To detect a background. The tone is explicit and never sniffed from the painted backdrop.
- For a pill shape.
rounded="full"is a fixed large radius, not a pill, because a container of arbitrary width would paint an ellipse. - For one button on a coloured plate. Set
tone="on-color"on that Button instead.
Installation
pnpm add @tessinaui/uiUsage
Surface solves a common contrast problem: a button that looks correct on the page background can become unreadable on a brand-colored hero, a dark section, or an inverted card. Rather than detecting the painted background at runtime (unreliable and SSR-unsafe), Tessina UI uses an explicit, inheritable tone: the region declares what it is painted on, and its children read that declaration instead of guessing.
Wrap a colored region in <Surface> and every nested Button, IconButton, Fab, SplitButton, ToggleButton, and ButtonGroup inherits tone="on-color". A per-component tone prop always overrides the inherited value.
import { Surface } from "@tessinaui/ui";
import { Button } from "@tessinaui/ui";{/* Recommended — `background` paints a paired bg + on-* ink and auto-enables on-color */}
<Surface background="primary" className="rounded-xl p-6">
<Button>Get started</Button> {/* solid inverse chip */}
<Button variant="secondary">Docs</Button>
<Button variant="ghost">Learn more</Button>
</Surface>
{/* Per-button override — opt one button back out */}
<Surface background="primary">
<Button>Inherited on-color</Button>
<Button tone="default">Forced default</Button>
</Surface>
{/* Raw mode — bring your own background, name the ink */}
<Surface ink="light" className="bg-[#3b0764] p-6">
<Button variant="outline">Outline</Button>
</Surface>Examples
Default
A colored surface where nested buttons inherit tone="on-color" automatically.
Backgrounds
The background sugar paints a paired fill and on-* ink — primary, error, warning, success, info, and foreground.
Rounded
Corner radius via the rounded prop — none, sm, md, lg, and full.
Per-button override
An explicit tone="default" opts a single button back out of the inherited surface tone.
Raw mode
Skip the background sugar — bring your own background and name the ink. Surface paints the text
colour and publishes the contract from it, so the solid button follows your plate.
The on-color contract, end to end
Every way a plate can publish the contract, in one frame: the paired background sugar, a
hand-painted dark plate (ink="light"), a hand-painted pale one (ink="dark"), and a plain nested
Surface that inherits rather than resetting the tone. Both ink directions are live here, so the
pale-plate path is rendered and measured rather than only described below.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tone | "default" | "on-color" | "on-color" when background or ink is set, else the nearest <Surface>'s tone ("default" at the top level) | Tone propagated to descendant button-family components. |
background | "none" | "primary" | "error" | "warning" | "success" | "info" | "foreground" | "none" | Convenience: paints a paired bg-* + on-* ink, publishes the --surface-ink / --surface-on-ink contract, and auto-enables on-color tone. |
ink | "light" | "dark" | — | Ink for a plate you paint yourself. Sets the text colour and publishes the contract from it, and enables on-color. Names the ink, not the theme: light is white text for a dark/saturated plate. Ignored when background is set. |
rounded | "none" | "sm" | "md" | "lg" | "full" | "none" | Corner radius on the surface container. full is rounded-3xl (24px), the library's top-of-scale for panel chrome — a literal rounded-full on a container of arbitrary width paints an ellipse, not a pill. |
render | React.ReactElement | — | Render into a custom element instead of a div (polymorphic). The element's own children are replaced by Surface's — put content in <Surface>'s children, not the render element's (doing the latter warns in development). A ref on the element is merged with Surface's, not discarded. |
className | string | — | Additional classes on the surface element. |
Hooks & helpers
| Export | Description |
|---|---|
useSurfaceTone() | Returns the current SurfaceTone from the nearest <Surface> (or "default" outside one). For building tone-aware components. |
resolveTone(prop, ctx) | Resolves a component's effective tone — explicit prop wins, else inherited surface tone. |
surfaceBackgroundClasses | The background recipes, keyed by name. A component painting its own coloured plate imports the recipe instead of retyping the var pair. |
surfaceBackgroundChildrenClasses | The same recipes with the contract on the plate's direct children, for a plate that draws its own focus ring (a solid Alert): the root paints the fill and the ink, and its ring reads the plate around it. |
surfaceInkClasses | .light / .dark — the ink half on its own, for a plate the component paints itself (a photo, a gradient, a pinned-dark card). What the ink prop applies. |
surfaceInkChildrenClasses | The same ink recipes with the contract on the plate's direct children, for a plate the component paints itself that draws its own focus ring (a pressable Card's cover and inverse plates): the root keeps the ink, and its ring reads the plate around it. |
Notes
- Recommended path: use the
backgroundsugar — it pairs the background with its matchingon-*ink so the inheritedcurrentColoris guaranteed legible in both light and dark themes. - Raw mode: when you supply your own background via
className, pair it withink— that sets the text colour the quiet variants ride on and publishes the contract the solid chip needs. Without it the chip falls back to white on an unknown backdrop. - Observable tone: the surface root carries
data-slot="surface"anddata-tone="default" | "on-color", plusdata-background="<name>"whenbackgroundpaints one anddata-ink="light" | "dark"wheninkapplies (both absent otherwise). Tests and computed-style gates can assert the resolved tone without reaching into React context. Arenderelement that declares its owndata-slotkeeps it. - No auto-detection:
Surfacedoes not inspect the painted background; it propagates an explicit, deterministic, build-time-verifiable tone (audited against WCAG AA).
How on-color styling works
Under tone="on-color" the whole family is built from one value — the ink the surface already
paints on itself. The tiers differ only in how much of that ink they carry:
| Variant | On-color treatment |
|---|---|
primary (intent none) | Solid inverse chip: 100% ink fill, labelled with --surface-on-ink. Because the fill is the surface's own text colour, it is readable against that surface by construction. |
primary (semantic intent) | Keeps its solid semantic fill (error, warning, …) — those already carry guaranteed on-* contrast on any surface. |
secondary | 8% ink tint — the field's well — ink label: a grey pill on a light surface, a frosted one on a dark surface. Hover and press take a 25% inset hairline, never a second wash. |
outline | 80% ink border (border-current/80), transparent rest; hover drops the border onto the 8% well, press adds the hairline. |
ghost | Transparent rest, ink label; hover climbs to the 8% well, press adds the hairline. |
Hover and press climb one rung and stop at the well: on-color text never sits on more than 8% of the
ink, in any state, because on the error, info and success plates full ink clears 4.5:1 only below ~9%.
So a control whose ground is already a well — a secondary pill, anything inside a Field, a soft
Toolbar — takes the hairline instead of more fill. Every well publishes that ground
(--on-color-hover-fill, --on-color-hover-ring), so a ghost button you nest inside one follows by itself.
A trigger whose menu is open holds that state, so it takes the pressed treatment rather than the hover
rung. A secondary, outline or ghost button whose menu is open (aria-haspopup="menu" with
aria-expanded="true") turns into the inverse chip, and a primary button, already the chip, holds its
hover step. The hold waits for a menu: a tooltip's trigger also carries data-popup-open, and a popover
or dialog trigger, such as the ColorPicker and DatePicker fields, keeps its own look while open.
This is the same light/dark logic as a themed button — solid = the contrasting extreme, secondary = a
muted step off the background — expressed relative to the surface rather than the page, so it
holds on any backdrop. Interaction tints use color-mix(in srgb, currentColor X%, transparent), and
the solid tier reads the two vars below, so every tier adapts to any surface colour.
The surface contract
<Surface> publishes three CSS variables (raw HSL triplets) scoped to its subtree:
| Variable | Meaning |
|---|---|
--surface-ink | The readable ink painted on this surface — what currentColor resolves to. |
--surface-on-ink | The maximum-contrast neutral to sit on that ink; the solid chip's label. |
--surface-fill | The fill the surface paints (or, on a hand-painted ink surface, the assumed plate). For cut-outs: a status dot's ring on an avatar reads as a hole in the surface only if it is this colour. Read it as hsl(var(--surface-fill, var(--background))) — outside any Surface it falls back to the page. |
The quiet tiers need no cooperation at all — they ride currentColor. Only the solid tier reads the
vars, because it needs both a fill and a label that contrasts it, and an element cannot set color
and still see the inherited ink through currentColor.
For a region we can't inspect (a photo hero, a hand-painted background="none" surface) the vars fall
back to white ink / black label in both themes — an unknown surface does not follow the page
theme. Use ink to take control in one word:
{/* pale plate → black text */}
<Surface ink="dark" className="bg-lime-200 p-6">
<Button>Get started</Button>
</Surface>
{/* photo or dark plate → white text */}
<Surface ink="light" className="bg-[url(/hero.jpg)] bg-cover p-8">
<Button>Get started</Button>
</Surface>ink names the ink, not the theme — light means light-coloured text. It sets the text colour,
publishes both vars from it, and turns on on-color for the subtree. Declaring the two custom
properties by hand still works and is what ink expands to, but there is no longer a reason to.
Muted inks on intent plates
Muted on-color ink — supporting text, descriptions and meta, counters, captions, the "(optional)"
marker, affixes, placeholders, resting inside labels, inactive tabs and segments, and resting
icons — is a dimmed copy of the ink, in every component. It reads one more variable,
--surface-ink-muted, so the plate decides whether its ink has room for that second tier:
| Plate | --surface-ink-muted | Muted inks render at |
|---|---|---|
primary, foreground, warning, an ink surface, the inverse overlay | initial | their own alpha (50–80%) |
error, info, success | 100% | full ink |
On error, info and success the full ink only reaches 4.6–5.6:1 against a field's 8% well, and
4.8:1 on the bare light-error plate, so a dimmed copy cannot clear WCAG AA's 4.5:1 there, in
either theme. Those plates keep one text tier. A solid Alert is its own plate and follows its
own intent the same way.
On an error, info or success plate, put a field's hint in its label or supporting text, not in a placeholder. A placeholder still renders there, at full ink, which is legible but reads like a value someone typed.
The reset matters when plates nest: a primary plate or an ink surface inside an error one
publishes initial, so its fields get their muted tier back instead of inheriting 100%.
Intent inks on intent plates
Intent colours on a plate — a required star, info and intent supporting text, an intent
Button, Label or Link, an invalid field's border and focus ring — come from four adaptive
variables, --on-ink-error, --on-ink-warning, --on-ink-success and --on-ink-info. Each
keeps its intent's hue and takes its lightness from the plate's ink, which clears 4.5:1 on
primary, foreground, warning and the inverse overlay, in a field's well too.
On error, info and success no hue can: not the plate's own intent, and not a red star on a
green plate either. Those plates publish all four variables as their own ink, so intent text
there reads like the rest of the plate's text and the words carry the meaning. A primary plate
or an ink surface nested inside one gets its hues back.
All four are opaque. A muted helper line or a disabled field does not dim an intent colour
inside it: an intent Link in supporting text reads the same as on its own, as it does on the
page.
Nesting
A <Surface> that paints no background of its own passes the surrounding tone
through rather than resetting it. This matters because the two halves of the
contract travel differently: --surface-ink is a CSS custom property and
inherits through any depth of markup, while the tone travels by React context. A
plain layout <Surface> inside a colored one would otherwise publish
tone="default" over a subtree whose ink was still the outer surface's — and
every button in it would drop back to page-theme styling on a colored plate.
<Surface background="primary">
<Surface rounded="lg" className="p-4">
<Button>Still on-color</Button> {/* inherits from the hero */}
</Surface>
<Surface tone="default" className="bg-background text-foreground p-4">
<Button>Back to page theme</Button> {/* an explicit tone still wins */}
</Surface>
</Surface>Set tone explicitly whenever a nested region paints its own background — a
light card inside a dark hero is tone="default", and a colored panel inside a
neutral one takes background (or ink in raw mode).
The library's own page surfaces do this for you: a Card, a soft Alert, a
painted Fieldset or Form, a framed Table, a filled TopHeaderDesktop and
the rest publish tone="default" over what they paint, and a tone-aware
container told tone="default" publishes it to its content. A variant that
paints nothing — ghost, plain, transparent — passes the surrounding tone
through, like a bare <Surface>, and draws its own text in the plate's ink. A
subtle tint (an outline Alert, Banner accent) is laid over the page
background, so it stays a light panel on a coloured plate. A borderless
Table reads the plate like the other paintless variants, and paints no wash
under its cells.
For component authors
Two rules, and they are gated.
Consuming the tone. A component that changes appearance on a coloured surface takes a tone
prop and resolves it against the context — explicit prop first, inherited tone second:
import { resolveTone, useSurfaceTone, type SurfaceTone } from "@tessinaui/ui/surface";
const resolvedTone = resolveTone(tone, useSurfaceTone());Publishing a surface. A component that paints its own coloured plate is an on-color surface, so it publishes the contract — from the shared recipes, never retyped:
import { surfaceBackgroundClasses, surfaceInkClasses } from "@tessinaui/ui/surface";
// a themed fill
solid: surfaceBackgroundClasses.foreground,
// a plate that does not follow the theme (a literal, a gradient, a photo)
inverse: `bg-neutral-900 ${surfaceInkClasses.light}`,pnpm audit:contrast fails on any hand-written [--surface-ink: outside components/surface. That
is not stylistic. Seven components used to retype the pair, and the copies drifted: tooltip and
alert pinned --surface-on-ink to a locked extreme, --on-<intent> went black in dark mode, and a
solid success call-to-action shipped as a black label on its own black chip — 1.00:1, in these
docs, invisible. One source means that cannot recur.
Non-goals
Decided, so they stop being re-asked per component.
- No padding, elevation or border API. Surface owns colour and radius. Spacing is the caller's —
className="p-6"is not a gap in the API. - No neutral backgrounds. There is no
background="card"or"muted"; those are default-tone regions. Paint them withclassNameand leave the tone alone. - No runtime background detection. The tone is explicit, inheritable and verifiable at build time. Sniffing the painted backdrop is unreliable and SSR-unsafe.
- The context carries
toneonly. Consumers derive everything from the ink, never from knowing which intent painted the surface — that is what makes a hand-painted plate work identically tobackground="error". rounded="full"isrounded-3xl, not a pill. Surface is a container of arbitrary width, whererounded-fullpaints an ellipse — the shape would track content width instead of the prop. Same step as navigation-menu, popover, context-menu, hover-card, alert-dialog, picker and file-upload.- No design-file counterpart is planned. Surface is a code-level primitive; the nearest analogue is a painted frame plus a text style, but nothing in this repo tracks or verifies that pairing.
Accessibility
Surface has no role, no ARIA attributes and no keyboard behaviour — it is a colour and tone container, so there is no APG pattern to follow. Its accessibility work is contrast, and it happens in the components it wraps.
- Nothing is announced. The root is a plain
div(or the element you pass torender, whose semantics it keeps). Give a region landmark semantics yourself —render={<section aria-labelledby="…" />}— when it needs them. - The tone is explicit, never sniffed. Contrast is guaranteed by the paired
ink
backgroundpublishes, not detected from pixels, so it holds on first paint and under SSR. In raw mode, pair your own background withinkor nested controls fall back to white on an unknown backdrop. - Text tiers are sized to the plate. On
error,infoandsuccessthe full ink only reaches 4.6–5.6:1 in a field's well, so those plates drop the muted text tier and publish intent inks as their own ink — the words carry the meaning, not a hue (see Muted inks and Intent inks). - The contract is gated, not reviewed.
pnpm audit:contrastfails any hand-written--surface-inkpair outside Surface, and Surface's own tests pin the muted ink, intent inks, hover rungs, wash ceiling, focus-ring gap, page-plate tone and popup tone. axecolor-contrastis clean on the demo and playground in both themes. - Observable for tests.
data-tone,data-backgroundanddata-inkon the root let a gate assert the resolved tone without reaching into React context.
Keyboard
| Key | Behaviour |
|---|---|
| — | None. Surface is not focusable; focus order is its children's |
AT matrix
Surface exposes nothing to assistive tech, so a screen-reader pass tests its children rather than Surface itself.
| AT | Status |
|---|---|
| VoiceOver + Safari (macOS) | Passed 2026-09-14. Surface adds nothing: the buttons on a primary plate read as "Get started, button" and "Learn more, button", with no group, region or colour announced. Transcript: tests/qa/vo-transcripts/surface.md |
| VoiceOver + Chrome (macOS) | UNTESTED |
| NVDA + Chrome (Windows) | UNTESTED — no Windows host available |
| JAWS | UNTESTED |
NVDA and JAWS stay untested until a Windows host exists (QA flow §5.6).