Theme

Optional React provider that writes the Spark theming attributes onto a wrapper.

Usage

import { Theme, useTheme } from '@epilot/spark-ui/theme'
<Theme accentColor="violet" radius="large">
  <App />
</Theme>

API Reference

PropMaps toValues
accentColorruntime --spark-*a curated name (blue, violet, …) or a hex
backgroundColorruntime --spark-* + --neutral-*any hex — the page canvas
neutralColordata-neutral-colorauto slate mauve gray sage olive sand, or a custom hex
radiusdata-radiusnone small medium large full
spacingdata-spacingdense default roomy
scalingdata-scaling90% 95% 100% 105% 110%
styleVariantdata-stylesolid soft surface ghost
highContrastdata-high-contrastboolean; orthogonal, strengthens any style toward higher-contrast steps
inverteddata-invertedboolean; orthogonal, re-casts the accent for controls on a solid accent surface
appearance.light / .dark classlight dark
fontHeadingruntime --spark-*any CSS font-family value, applied to h1h6
fontBodyruntime --spark-*any CSS font-family value, applied to everything else
renderreplace the rendered element / compose with your own component

accentColor is the one runtime knob: the accent scale is generated into --spark-* variables at render time (no static CSS), so curated names and custom hex both work.

neutralColor also accepts a custom hex alongside the 6 named families and auto. The DOM attribute stays one of the 6 family names either way — a raw color isn't one of the small set of values CSS can select on, so it falls back to its accent-matched family there — but generation itself uses the real color you passed. That generation only happens alongside a backgroundColor, though: without one, nothing regenerates the neutral scale at all, so a custom neutralColor with no backgroundColor set is a no-op — the static family the attribute falls back to is what actually paints.

spacing and scaling both change size but are independent. scaling is a global zoom that resizes everything — padding, control sizes, type, and gaps. spacing (density) is narrower: it adjusts only the space between components in layout and collection primitives (PageContainer, FieldSet, List, RadioGroup, …) and never resizes a component's own internals.

styleVariant sets the emphasis of the primary / accent action (solidghost, via --spark-style-*). Buttons with an explicit styleVariant/color don't change with the ambient style.

Where knobs take effect.

  • At your app root (<html>, or a <Theme> around your app): every knob applies.
  • Lower in the tree: accent / neutral / style cascade to components too (their color tokens are @theme inline, so bg-spark-* / bg-gray-* re-resolve per scope), as does radius="full". The factor radii (nonelarge) and scaling / spacing stay root-only for now.

The provider applies accent as inline styles, so it's sRGB-only. Wide-gamut P3 needs the scale emitted into a <style> (via the library's accentSparkVarsP3), which inline styles can't carry.

Accents (runtime-generated)

Each accentColor (a curated name or a custom hex) generates the full Radix-style scale: the solid steps (--spark-1…12) and the alpha steps (--spark-a1…a12). These ramps read the raw vars, so they update live per accent:

blue
123456789101112
a1a2a3a4a5a6a7a8a9a10a11a12

Understanding the scale

Spark follows Radix's 12-step scale semantics, so every color decision picks the step designed for that use case:

StepsUse caseSpark examples
1–2App & subtle backgrounds--spark-background, base-*-surface
3–5Component backgrounds (rest / hover / active)input-base (a3), input-base-hover (a4), selection (a5)
6–8Borders: 7 = UI borders & focus rings, 8 = hover/stronginput (a7), input-active + input focus rings (a8)
9–10Solid backgroundsbase-*-solid (9), global focus-root ring (10)
11–12Text: low contrast (11), high contrast (12)gray-light / accent-light (a11), gray / text (a12)

The global focus ring (buttons, checkboxes, …) deliberately uses step 10 instead of Radix's step 8 (step 8 is ~2.3:1 on light surfaces, below WCAG 1.4.11's 3:1). Inputs follow the border steps instead: their border or underline steps from 7 to 8 on focus, with a soft step-4 halo.

Background

backgroundColor sets the page canvas. It is not just a paint value — every alpha step in the scale is solved against the canvas (target = bg·(1-α) + fg·α), so a custom canvas regenerates both the accent scale and the neutral scale against it. That second half is what makes a saturated colour work: --neutral-a* paints nearly all body text and borders, and the static Radix neutrals are pre-baked against white, so on yellow they would tint and lose their contrast guarantee.

#FFD400

Neutral ink, regenerated against the canvas.

#F0EAD6

Neutral ink, regenerated against the canvas.

#0B1020

Neutral ink, regenerated against the canvas.

Two behaviours worth knowing:

  • A canvas has a polarity. A yellow page is a light page. So the custom canvas applies to the appearance it belongs to and the other keeps its default — set backgroundColor on a <Theme appearance="dark"> scope to give dark mode its own canvas. Handing a scope a canvas of the wrong polarity logs a warning rather than painting unreadable ink.
  • Ink steps go opaque where they must. From a saturated canvas, a desaturated ink is unreachable with a translucent overlay, so those steps fall back to their opaque value rather than silently rendering unreadable text. Low steps (subtle fills, borders) stay translucent, because they still solve exactly.
  • The accent scale tints toward the canvas too. Steps 1–5 — the soft-card/checkbox fill territory — blend toward the canvas, fading out by step 6; the solid brand colour (step 9) and everything above it never moves. This keeps the accent and neutral scales agreeing on the same tinted page instead of one following the canvas and the other staying a flat wash of itself. Accent and canvas far apart in hue desaturate toward each other rather than sweeping through an unrelated vivid hue along the way. Those same 5 steps' translucent alpha versions fall back to opaque too if reproducing them there needs more than roughly half-opaque — at that point it's no longer a usable tint, just a solve that happens to hold over one specific canvas.
  • neutralColor="auto" prefers the canvas's own hue. When backgroundColor is set and perceptibly tinted (OKLCH chroma ≥ 0.04), auto seeds the neutral scale directly from that colour instead of bucketing it into one of the 6 named families — a warm canvas visibly warms the neutrals this way, where the nearest bucketed family barely would. With no backgroundColor, or one too close to grey to register, auto falls back to pairing with the accent, same as before.

No neutralColor set

Neutral ink stays cool — the blue accent's own family.

neutralColor="auto"

Neutral ink warms to match the canvas instead.

Production portals generate their own tokens (ECP's generator, pushed over postMessage), so a custom canvas reaches live tenants only once that generator supports it — the same staging that already applies to a custom accent hex.

Inverted

A solid control on a solid accent surface paints the same --spark-9 as the surface, so it disappears. inverted re-casts the accent scale for that surface: step 9 swaps with its contrast ink, so the solid fill and its label are a proven pair.

The quieter styles need a second idea, because that ink is also the text colour — a fill blended toward it drags the background into the foreground. So fills step the other way, toward the lightness pole opposite the ink, while borders keep stepping toward it.

default

inverted

It's orthogonal to styleVariant and composes with highContrast, because it re-points the accent scale itself rather than adding style recipes — every data-style variant re-colours from it for free.

Because --spark-contrast is already the WCAG-checked ink for step 9, and every other fill moves away from that ink rather than toward it, each style's label is contrast-safe by construction for any accent — measured worst case 4.81:1 for text and 3.01:1 for borders across the curated set. It needs no separate dark-mode values either: the generator pins step 9 to the accent itself, so the surface — and therefore the inverted scale — is identical in both appearances.

Reach for it when you've deliberately put controls on an accent fill. On a neutral solid card the normal scale already reads fine, so leave it off.

An accent Card/Box nested inside inverted re-colours too — its label re-points to the accent ink for the same reason the styles above do, so nesting one accent card inside another still reads correctly.

Typography

fontHeading and fontBody write --spark-font-heading / --spark-font-body as inline styles, the same mechanism accentColor uses — free-form font stacks, not a fixed set of presets. fontHeading applies to h1h6; fontBody to everything else. Neither is set by default, so an app that never touches them keeps whatever font it already had — spark-ui itself has no opinion on typography beyond these two hooks.

This heading uses fontHeading

This paragraph uses fontBody.

Nesting & the cascade

Only the props you pass are written, so a nested Theme overrides just those knobs and inherits the rest. The override flows through normal CSS inheritance (here the inner scope re-tints just the accent):

blue (theme)
red (override)

useTheme()

Returns the theme knobs from enclosing <Theme> providers (this one merged over its ancestors). It reflects only props passed to a <Theme>. It does not read data-* attributes or the appearance class set outside a provider. Returns {} when there is no <Theme> ancestor.

const { accentColor, radius } = useTheme()

On this page