DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
SurfaceContainerStackFlexGridSpacerDividerAspectRatioScroll Area
AI agentsContributing
  1. Components
  2. Surface

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/ui

When 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, ToggleButton and ButtonGroup switches to on-color styling.
  • A fill with a paired ink. background paints the fill and its matching on-* 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" or ink="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. rounded sets 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 with className and 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

Terminal
pnpm add @tessinaui/ui

Usage

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.

example.tsx
import { Surface } from "@tessinaui/ui";
import { Button } from "@tessinaui/ui";
example.tsx
{/* 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

PropTypeDefaultDescription
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.
renderReact.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.
classNamestring—Additional classes on the surface element.

Hooks & helpers

ExportDescription
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.
surfaceBackgroundClassesThe background recipes, keyed by name. A component painting its own coloured plate imports the recipe instead of retyping the var pair.
surfaceBackgroundChildrenClassesThe 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.
surfaceInkChildrenClassesThe 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 background sugar — it pairs the background with its matching on-* ink so the inherited currentColor is guaranteed legible in both light and dark themes.
  • Raw mode: when you supply your own background via className, pair it with ink — 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" and data-tone="default" | "on-color", plus data-background="<name>" when background paints one and data-ink="light" | "dark" when ink applies (both absent otherwise). Tests and computed-style gates can assert the resolved tone without reaching into React context. A render element that declares its own data-slot keeps it.
  • No auto-detection: Surface does 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:

VariantOn-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.
secondary8% 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.
outline80% ink border (border-current/80), transparent rest; hover drops the border onto the 8% well, press adds the hairline.
ghostTransparent 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:

VariableMeaning
--surface-inkThe readable ink painted on this surface — what currentColor resolves to.
--surface-on-inkThe maximum-contrast neutral to sit on that ink; the solid chip's label.
--surface-fillThe 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:

example.tsx
{/* 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-mutedMuted inks render at
primary, foreground, warning, an ink surface, the inverse overlayinitialtheir own alpha (50–80%)
error, info, success100%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.

example.tsx
<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:

example.tsx
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:

example.tsx
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 with className and 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 tone only. Consumers derive everything from the ink, never from knowing which intent painted the surface — that is what makes a hand-painted plate work identically to background="error".
  • rounded="full" is rounded-3xl, not a pill. Surface is a container of arbitrary width, where rounded-full paints 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 to render, 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 background publishes, not detected from pixels, so it holds on first paint and under SSR. In raw mode, pair your own background with ink or nested controls fall back to white on an unknown backdrop.
  • Text tiers are sized to the plate. On error, info and success the 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:contrast fails any hand-written --surface-ink pair 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. axe color-contrast is clean on the demo and playground in both themes.
  • Observable for tests. data-tone, data-background and data-ink on the root let a gate assert the resolved tone without reaching into React context.

Keyboard

KeyBehaviour
—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.

ATStatus
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
JAWSUNTESTED

NVDA and JAWS stay untested until a Windows host exists (QA flow §5.6).

ComponentsContainer

On this page

When to useWhen not to useInstallationUsageExamplesDefaultBackgroundsRoundedPer-button overrideRaw modeThe on-color contract, end to endAPI ReferencePropsHooks & helpersNotesHow on-color styling worksThe surface contractMuted inks on intent platesIntent inks on intent platesNestingFor component authorsNon-goalsAccessibilityKeyboardAT matrix