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.
Playground
Overview
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 same approach taken by Material 3, Radix, and Adobe Spectrum.
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.
Installation
pnpm add @tessinaui/uiUsage
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.
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 | 15% ink tint, ink label — a grey pill on a light surface, a frosted one on a dark surface. |
outline | 60% ink border (border-current/60), transparent rest, ink-tinted hover. |
ghost | Transparent rest, ink label, ink-tinted hover only. |
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%.
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).
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 Figma 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.
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. |
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. |
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).