DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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:

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.
secondary15% ink tint, ink label — a grey pill on a light surface, a frosted one on a dark surface.
outline60% ink border (border-current/60), transparent rest, ink-tinted hover.
ghostTransparent 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:

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:

{/* 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%.

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

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.
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 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).
StepperSwitch

On this page

PlaygroundOverviewInstallationUsageExamplesDefaultBackgroundsRoundedPer-button overrideRaw modeHow on-color styling worksThe surface contractMuted inks on intent platesNestingFor component authorsNon-goalsAPI ReferencePropsHooks & helpersNotes