Skip to content

Styles & Design Tokens

The SDK exposes a set of CSS Custom Properties (--tempest-*) that control the entire appearance of the components. Consumer apps customize the theme by overriding these tokens — there's no need to touch CSS Modules.

import "tempest-react-sdk/styles.css";

Done. Everything below is already available in your application.

Override tokens on :root

The only way to theme is to redefine the --tempest-* tokens in your own CSS. Put your overrides in a :root (or a subtree for partial scoping) after the import — never edit the SDK's CSS Modules.

Tokens are a public API

The --tempest-* names are part of the SDK's semver contract. See the versioning policy at the end of the page before you depend on a specific token.

utilities.css (camada opt-in) in the gallery

Section utilities-css of the gallery — run it locally to interact.

Summary


Importing less CSS

Every component carries its own CSS. import { Button } brings the button's markup and the button's stylesheet with it — you list no sheets, and there is no global component sheet to import:

// src/main.tsx — the two foundation lines, and nothing else
import "tempest-react-sdk/styles/tokens.css";
import "tempest-react-sdk/styles/scoped.css";

Measured in a Vite app mounting Button, Card and Badge:

What the app imports raw brotli
styles.css (the whole sheet) 241.73 kB 29.15 kB
foundation + the components mounted 27.40 kB 4.49 kB
same, with tempestStyles() narrowing the tokens 21.86 kB 3.37 kB

The same app under webpack, with no plugin at all: 28.53 kB raw / 4.88 kB brotli. The cut depends on neither a plugin nor a bundler — every published *.module.js imports its component's sheet, and sideEffects: ["**/*.css"] in package.json is what lets a bundler drop the sheet of a component the app never reaches.

Outside a bundler, use the loader the package ships

The .css import that makes this work is the one Node cannot load: node -e "import('tempest-react-sdk')" dies with ERR_UNKNOWN_FILE_EXTENSION. Vite, webpack, Rspack, Parcel and esbuild resolve that import; bare Node, a loose .mjs script and Jest's require() do not. The SDK is client-side only and always runs behind a bundler, so no real app consumer is left out — but your script, your test runner and any read of the surface by introspection are.

For those, the package ships the piece instead of telling you to write it:

node --import tempest-react-sdk/node-css-loader your-script.mjs

Or from inside the script, before the first import of the package:

import "tempest-react-sdk/node-css-loader";

const sdk = await import("tempest-react-sdk");
console.log(Object.keys(sdk).length);

It resolves every stylesheet to an empty module, for import and require — nothing about the styles is emulated, which is the correct answer in a context that paints nothing.

Under Vitest, createViteConfig already handles it: it includes tempestVitest(), which makes Vitest transform the SDK instead of handing it to Node. With a vitest.config.ts of your own, add the plugin — without it the suite dies with Unknown file extension ".css" (details in Testing with Vitest). Under Jest, map the extension to a stub, which is the same configuration any project with CSS Modules already carries:

// jest.config.js
export default {
  moduleNameMapper: { "\\.css$": "<rootDir>/test/css-stub.js" },
};

With test/css-stub.js exporting an empty object (module.exports = {}).

Same render, less CSS

Measured in a real browser, 37 selectors compared with transition disabled, in light and dark: the computed styles of the whole styles.css and of the per-component path are identical. What changes is how much CSS arrives, not what it does.

styles.css is still published

One line, all 132 components, and nothing breaks: the bundler deduplicates the sheet a component imports against the one you imported. It costs bytes, never correctness — the same trade auto.css makes.

You still need the foundation

The foundation is the one thing a component does not bring, and that is not an oversight: the tokens live in :root, and a per-component sheet repeating them would put N blocks of equal specificity against your app's own :root — createTheme and any override of yours would stop winning. Measured, inlining them costs +14 kB raw to give back 351 B brotli, so the trade is bad in bytes too.

It comes in four pieces, and which combination to use depends on who owns the document — the two lines above are the answer for an app with its own layout, and When your app already has its own layout carries the table of all four and the reason for each.

Importing a sheet by hand

The older paths still stand, for the case a bundler's scan cannot reach the component — a render through dangerouslySetInnerHTML, markup assembled by another package, a page that only exists at runtime:

import "tempest-react-sdk/styles/core.css";
import "tempest-react-sdk/styles/component/Button.css";
import "tempest-react-sdk/styles/forms.css";
Entry What it brings When
styles.css everything one line, and the weight does not bother you
styles/core.css foundation, zero components always, alongside any of the others
styles/inverse.css the SDK blue's inverse surface — opt-in, not in styles.css you paint a section in the brand and do not generate the theme with inverse
styles/<Group>.css a whole family you use most of it
styles/component/<Component>.css one component you want the minimum

Groups available: actions, advanced, br, chat, data, editor, feedback, forms, geo, icons, identity, layout, media, navigation, overlay, utility.

Prefer styles/component/<X>.css over styles/<X>.css

styles/<Component>.css exists and works — it is a one-line @import of the file under component/. But two component names collide with a group name on a case-insensitive file system: Chat.css with chat.css, Layout.css with layout.css. On macOS and Windows the group wins, so those two imports hand back the whole family instead of the component — more bytes, never fewer rules. The component/ path does not have that problem by construction, and it is the path the component itself uses.

The split is exact, not a prune

Every class is hashed per CSS module (tempest_[local]_[hash]), and every dist/**/*.module.js carries its source path alongside the names that module declares — attributing a rule to a component is a lookup, not a guess. The build fails if any rule names classes from two modules, which is what would make the split a guess — and it is also what guarantees the order the sheets arrive in does not matter.

When your app already has its own layout

core.css is the foundation and a global reset at once, and the reset claims the whole document:

*, ::before, ::after { box-sizing: border-box }
:where(html, body) { height: 100% }
:where(#root) { min-height: 100% }
:where(#root:has(> :only-child)) { height: 100% }
body { margin: 0; background: var(--tempest-bg); color: var(--tempest-text) }
button { background: none; border: 0; padding: 0 }
:where(ul, ol)[class] { list-style: none; margin: 0; padding: 0 }

In an app the SDK builds end to end, that is exactly what you want. In an app that already has its own layout, it is the SDK taking over the document surface — your button loses its border and background, your list loses its markers, body loses its colour.

The obvious way out — drop the import — is worse, for a reason that is not visible at first: the components are written against the reset. .tempest_button relies on button { background: none; border: 0 }, every width calculation relies on box-sizing: border-box, the fields rely on font-family: inherit. Without the reset the problem is not polish, it is the box model.

So the foundation ships as three pieces rather than one:

Entry What it carries Touches your markup?
styles/tokens.css the 220 --tempest-* tokens (373 declarations, counting dark and the densities) + color-scheme no — paints no markup
styles/scoped.css the reset confined to :where([class*="tempest_"]) no — inside components only
styles/base.css the global reset (html, body, #root, button…) yes, that is its job
styles/core.css tokens + base, as it always was yes

An app with its own layout swaps core.css for two lines:

import "tempest-react-sdk/styles/tokens.css";
import "tempest-react-sdk/styles/scoped.css";

color-scheme travels with the tokens, not the reset

It is not a custom property, but it is what tells the browser to paint its own surfaces — scrollbar, <select> popup, autofill, date picker — in the active theme. No .tempest_* rule can reach those. Leaving it with the global reset would give you the SDK's colours with the browser's chrome still light.

:where() is what keeps this a republish

Every selector in scoped.css is the original wrapped in :where([class*="tempest_"]), which contributes zero specificity. Each rule weighs exactly what it weighed globally, so an app override that used to win still wins.

Focus on your own elements

base.css declared :focus-visible unscoped, so every focusable element on the page — yours included — got the SDK's ring. scoped.css does not, by design: it reaches the node carrying a tempest_ class and whatever is inside it, and nothing else.

What your elements inherit instead is not "nothing": it is the user agent's ring. Measured in Chromium, an app <button> over #14213d: outline: rgb(16,16,16) auto 1px — 1.19:1. Invisible focus, in practice.

Migrating from core.css to the scoped pair without reading this regresses accessibility

There is no error, no warning and no visual difference. The page looks the same until somebody navigates by keyboard. If your app declares no :focus-visible of its own, declare one as you migrate.

One rule brings the SDK's ring back to your markup, on the same tokens:

:focus-visible {
    outline: var(--tempest-focus-ring-width) solid var(--tempest-focus-ring-color);
    outline-offset: var(--tempest-focus-ring-offset);
}

Writing it is opt-in, which is the point of scoping — but the step is mechanical and always has the same right answer, so it is spelled out here rather than left to be worked out.

color-scheme reaches the document through the attribute, not the sheet

The UA ring above came out rgb(16,16,16) — the light scheme value — even with color-scheme: dark inherited. If you scope the theme to a subtree instead of putting data-tempest-theme on <html>, the surfaces the browser paints stay in the light scheme.

The plugin — tempestStyles()

Each component's sheet now arrives on its own, so the plugin is no longer there to assemble an import list. It is there for the piece that is left: the foundation.

tokens.css is 220 tokens, and an app mounting three components reads 105 of them. The other 115 are bytes nothing on the page can consult — and now that components no longer drag the whole sheet along, that surplus is the single largest item of CSS the app downloads. Measured on the Button + Card + Badge app: 2516 B brotli of tokens against 1689 B for every component sheet put together.

The plugin reads the sheets the app will load, closes over the tokens they consult — an alias like --tempest-primary: var(--tempest-primary-500) brings its target along — and emits only those:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tempestStyles } from "tempest-react-sdk/vite";

export default defineConfig({
    plugins: [react(), tempestStyles()],
});
// src/main.tsx — one line, forever
import "tempest-react-sdk/styles/auto.css";
What the app imports brotli
tokens.css + scoped.css + the 3 sheets 4.49 kB
auto.css with the plugin 3.37 kB

The @import of the component sheets is still there and costs nothing: the bundler deduplicates it against the sheet the component imported itself — measured, the Button base rule appears once in the served CSS.

Options

tempestStyles({
    dir: "src",            // where to scan. Default: "src"
    reset: "scoped",       // "scoped" | "global" | "none". Default: "scoped"
    tokens: "used",        // "used" | "all". Default: "used"
    include: ["Toast"],    // names the scan cannot see
});
reset Emits When
"scoped" tokens + scoped.css your app owns its layout (default)
"global" tokens + base.css the SDK mounts the whole page
"none" tokens your app already has an equivalent reset
tokens Emits When
"used" only the tokens the sheets reach default
"all" the whole tokens.css your app reads tokens where the scan does not look

The cut scans your CSS too

You are free to read --tempest-* in CSS of your own — it is documented, and cutting a token your .app-card consults would not make the sheet smaller, it would leave the property with no value. So the scan covers the .css/.scss/ .less files under dir, not only the .tsx ones.

A token name built at runtime falls back to the whole sheet

style={{ color: `var(--tempest-${tone})` }} names a token no static scan can know. The plugin detects the construction and emits the whole tokens.css — serving more than asked for, never less than needed. It is the same design as the namespace-import fallback below. A token read from outside dir (another package, a <style> block in index.html) is what tokens: "all" is for.

Without the plugin, auto.css is still correct

tempest-react-sdk/styles/auto.css is a real file, which without the plugin resolves to the complete sheet. Dropping the plugin costs bytes, never correctness — the same design as tempest-react-sdk/icons/virtual.

A namespace import turns the scan off

import * as sdk from "tempest-react-sdk" resolves sdk.Button at runtime, so no static set of names is knowable. The plugin detects that and falls back to the complete sheet rather than serving too little — if you want the cut, import by name.

Color

Brand — primary tints

Scale 50 (lightest) → 900 (darkest). Use --tempest-primary as the canonical action color.

--tempest-primary-50: #eef4ff;
--tempest-primary-100: #d9e6ff;
--tempest-primary-500: #0066ff; /* === --tempest-primary */
--tempest-primary-700: #003d99;
--tempest-primary-900: #001f4d;

Aliases:

  • --tempest-primary = primary-500
  • --tempest-primary-hover = primary-600 in light, primary-600 in dark (the dark ramp is inverted, so 600 is lighter than 500)
  • --tempest-primary-active = primary-700
  • --tempest-primary-soft = primary-50 (tinted background for soft buttons/badges)
  • --tempest-primary-foreground = #ffffff in light, #1f0606 in dark (text color on top of primary)
  • --tempest-primary-on-soft = primary-600 in light, primary-700 in dark (text/icon color on top of --tempest-primary-soft)

Text on a saturated fill: *-on-solid

  • --tempest-danger-on-solid · --tempest-info-on-solid = #ffffff in light, #1f0606 in dark
  • --tempest-success-on-solid · --tempest-warning-on-solid = #1f0606 in both themes
  • --tempest-neutral-on-solid = #ffffff in both themes (the neutral fill is --tempest-gray-700, and the gray ramp does not invert)

#ffffff on a status fill is not safe, and never was

Measured against the light theme's own fills — the default — white on --tempest-success-solid is 3.30:1 and on --tempest-warning-solid 3.19:1. Mid-green and amber simply cannot carry white text; every design system that ships them puts dark text on top. In the dark theme, where the fills are lighter, all of them fail: primary 3.68:1, danger 3.76:1, info 3.68:1, success 2.28:1, warning 2.15:1.

So the text colour is a token per status rather than a hardcoded #ffffff. The dark ink is a near-black tinted toward its own hue (#1f0606), not pure black — it reads as part of the swatch instead of a hole in it.

In dark, hover and active go lighter

The primary scale is inverted for the dark theme (300 is the darkest step, 900 the lightest), so reaching for 400/300 on hover made the button get darker under the pointer — the light-theme gesture applied to a dark surface. It also made the fill outrun its own text: no single foreground cleared 4.5:1 against #3b82f6, #2563eb and #1a4399 at once. Going up the ramp fixes both.

A test holds this

src/styles/contrast.test.ts computes the ratio of every (text, fill) pair the SDK renders, straight from colors.css, in both themes. Redefine the palette and drop a pair below 4.5:1 and the test fails — instead of the problem showing up in someone's product. jsdom's axe does not catch this: it disables color-contrast because there is no paint.

And a browser sweep covers the rest

The test above measures the pairs the SDK declares. What it cannot see is what a page composes: text over a tinted surface, over an overlay, over an image. For that, e2e/gallery.spec.ts runs axe in real Chromium across a four-cell matrix — light and dark, at 1280px and 390px — and reads both lists axe returns.

Reading incomplete is the point. color-contrast lands there, not in violations, whenever axe cannot resolve what is behind the text — which is exactly the tinted-surface case. A sweep that reads only violations is looking in the drawer the finding is not in.

The measured numbers per cell live in e2e/axe-baseline.json, and the test fails when one grows past the slack — two nodes, or 2% for the big rules. The slack has the same design as the coverage floors, and the same reason: the gallery renders demos that depend on the clock, so the same build measured twice does not always report the same count. Measured: 87 and then 88 color-contrast incomplete nodes, with nothing changed in between. It is a ratchet, not an allowlist: the gallery renders 67 demo sections and carries real accessibility debt, and failing all of it would leave the build red forever — which is how a gate gets deleted. A drop does not fail; the numbers are there to be lowered.

Measuring contrast without disabling transition gives a false positive

Flipping data-tempest-theme starts the color transition on every component that declares one, so reading getComputedStyle a frame or two later samples the animation: the outgoing theme's foreground against the incoming theme's background. Measuring the VideoPlayer that way gave 7.32 and then 2.33 between runs — and 2.33 looks exactly like a real defect.

Inject *{transition:none!important} before measuring, and check that light → dark → light returns the same number. If it does not, the measurement is wrong, not the CSS.

Two names for one job is the gap a defect walks through

The dark theme shipped 73 contrast violations, measured in Chromium, and the three causes were the same thing from different angles: a text token nobody validated against the surface it lands on.

Cause Measured Fix
--tempest-text-subtle in dark 4.04:1 over surface, 4.37 over bg #6f7889 → #7b849a (4.79 / 5.19)
--tempest-primary-contrast in Scheduler token does not exist, so the #fff fallback always won → 3.67:1 points at --tempest-primary-foreground
--tempest-text-on-primary declared white in :root and never overridden in dark → 3.67:1 follows var(--tempest-primary-foreground)

--tempest-primary-foreground was already right — somebody measured the 3.68:1 and fixed it, writing in colors.css that "all 16 uses" pointed at it. The measurement was right; the count was not: AppBar used the twin and Scheduler used a name that does not exist.

If you redefine the palette, redefine both names. And prefer the canonical one: --tempest-text-on-primary exists for compatibility and follows the other.

A var() with a fallback hides a token that does not exist

The CSS analysis in tempest doctor does not report var() with a fallback, on purpose: without that rule the knob idiom (var(--tempest-card-padding, 1rem)) produced 43 false positives and the tool would be ignored. The price is this blind spot — var(--tempest-primary-contrast, #fff) compiles, runs, and paints white forever.

When writing a var() with a fallback, ask whether the name is a knob (the consumer defines it) or a purpose token (the SDK defines it). In the second case the fallback is hiding a typo.

Text on primary-soft uses primary-on-soft, not primary

--tempest-primary over --tempest-primary-soft yields 4.37:1 — WCAG AA asks for 4.5:1 on text. That is why --tempest-primary-on-soft exists (~6:1). If you redefine the palette, redefine both: overriding only --tempest-primary leaves the selected states (Toggle, ToggleGroup, ListTile, Stepper, NavigationRail, FileUpload) out of compliance.

To swap the entire brand:

:root {
  --tempest-primary-500: #7c3aed; /* purple */
  --tempest-primary-600: #6d28d9;
  --tempest-primary-700: #5b21b6;
  --tempest-primary-soft: #ede9fe;
}

Neutrals — gray scale

--tempest-gray-50: #f8f9fb;
--tempest-gray-500: #667085;
--tempest-gray-900: #101828;

Semantic aliases:

Token Use
--tempest-bg Canvas background
--tempest-surface Cards, headers, footers
--tempest-surface-2 Elevated surface (chip, button hover)
--tempest-surface-3 More elevated surface
--tempest-border Default border
--tempest-border-strong Higher-contrast border
--tempest-text Primary text
--tempest-text-muted Secondary text
--tempest-text-subtle Tertiary text (placeholders)

Status — triplets (fg/bg/border/solid)

Each status (success, warning, danger, info) exposes 4 colors:

--tempest-success-fg:     /* text over soft bg */ --tempest-success-bg: /* tinted soft background */
  --tempest-success-border: /* outline border */ --tempest-success-solid: /* solid fill */;

Shortcuts:

  • --tempest-success — main color (same as the dark success solid in light, lighter in dark).
  • --tempest-danger-hover — variation for hover on danger solid.

Components that accept appearance="soft|solid|outline" (Badge, Alert, etc.) automatically pick the right combination.

Data viz — series colors

Eight categorical colors in cycle order, plus the chart chrome:

Token Use
--tempest-chart-1 … -8 Series colors, applied by index (they cycle)
--tempest-chart-grid Grid lines
--tempest-chart-axis Axis lines and labels

The eight are spaced by hue — they are categorical, not a ramp. For a sequential or diverging scale, use --tempest-primary-*.

The tempest-react-sdk/charts module reads these tokens at runtime and re-resolves when the theme flips, so overriding them moves the charts without touching a single prop:

:root {
  --tempest-chart-1: #0f766e;
  --tempest-chart-2: #f97316;
}

Do not pass var() as a series color

Recharts applies color as an SVG presentation attribute, where var() is not resolved. That is why the SDK reads the token via getComputedStyle and hands over a literal color. Details in Charts › Colors and theming.

Generating the palette with createTheme

Writing a brand's ~30 values by hand (ten steps × light/dark + aliases) is tedious and easy to get wrong in dark, where the ramp inverts. createTheme derives all of it from one color, in OKLCH:

import { applyTheme, createTheme } from "tempest-react-sdk";

applyTheme(createTheme({ primary: "#7c3aed", radius: "lg" }));

It emits the same tokens documented on this page — it is sugar over the token API, not a second theming system. Full guide in Theme › createTheme.


Syntax colours (--tempest-code-*)

CodeBlock paints with ten tokens of its own: comment · punctuation · string · number · keyword · literal · function · tag · attribute · property.

Do not reuse the chart ramp for text

It is tempting — the eight series colours already exist and are already validated. But they are validated at the mark floor: 3:1, which is what WCAG asks of a graphical element. A syntax colour is text, and text needs 4.5:1. Measured as text in a browser, a keyword in --tempest-chart-1 came out at 3.47:1 on the dark surface and a string in --tempest-chart-3 at 2.03:1 on the light one. Both pass as marks and fail as text.

Each code token was solved in OKLCH: fix the hue, then search for the highest lightness (in dark mode, the lowest) that still clears AA against both grounds the token can land on — the block surface, and a highlighted line once the 10% primary wash composites over it. Solving against the surface alone is not enough: the highlight moves the ground, and a keyword measured 4.17:1 on it.

src/styles/colors.contrast.test.ts reads colors.css and re-checks every token in both modes against both grounds. If you override these tokens in your app, redo that arithmetic.

Typography

Families

--tempest-font-sans:    /* system stack */ --tempest-font-mono: /* monospace stack */
  --tempest-font-display: /* === sans, override for heading */;

Sizes

Token Pixels
--tempest-text-2xs 10px
--tempest-text-xs 12px
--tempest-text-sm 13px
--tempest-text-base 14px
--tempest-text-md 15px
--tempest-text-lg 16px
--tempest-text-xl 18px
--tempest-text-2xl 20px
--tempest-text-3xl 24px
--tempest-text-4xl 30px
--tempest-text-5xl 36px
--tempest-text-6xl 48px

Line heights

--tempest-leading-none|tight|snug|normal|relaxed|loose (1.0 → 1.9).

Weights

--tempest-weight-regular|medium|semibold|bold|extrabold (400 → 800).

Letter spacing

--tempest-tracking-tight|normal|wide|wider|widest.


Spacing

Base 4px. Goes from 0 up to 24 (96px).

--tempest-space-0: 0 --tempest-space-1: 4px --tempest-space-2: 8px --tempest-space-3: 12px
  --tempest-space-4: 16px --tempest-space-5: 20px --tempest-space-6: 24px --tempest-space-7: 28px
  --tempest-space-8: 32px --tempest-space-10: 40px --tempest-space-12: 48px --tempest-space-16: 64px
  --tempest-space-20: 80px --tempest-space-24: 96px;

Radius

--tempest-radius-xs: 2px --tempest-radius-sm: 4px --tempest-radius-md: 8px /* default controls */
  --tempest-radius-lg: 12px /* default cards */ --tempest-radius-xl: 16px /* modals */
  --tempest-radius-2xl: 24px --tempest-radius-full: 9999px;

Elevation (shadow)

--tempest-shadow-xs:    /* hairline, controls at rest */ --tempest-shadow-sm: /* default card */
  --tempest-shadow-md: /* card hover, dropdown */ --tempest-shadow-lg: /* drawer, popover */
  --tempest-shadow-xl: /* modal */ --tempest-shadow-inner: /* tracks, sunken inputs */;

Shadows are automatically darker in the dark theme.


Motion

Duration

--tempest-duration-instant: 0ms --tempest-duration-fast: 120ms /* hover, focus */
  --tempest-duration-base: 180ms /* default enter/leave */ --tempest-duration-slow: 280ms
  /* drawer, modal */ --tempest-duration-slower: 420ms;

Easing

--tempest-ease-linear
--tempest-ease-in
--tempest-ease-out
--tempest-ease-in-out
--tempest-ease-emphasized  /* enter animations */
--tempest-ease-bounce

Composite shortcuts

--tempest-transition-color:      /* color + bg + border, fast */ --tempest-transition-shadow:
  /* box-shadow, base */
  --tempest-transition-transform: /* transform, fast */
  --tempest-transition-base: /* everything above + opacity */;

Reduced motion

@media (prefers-reduced-motion: reduce) zeroes out all token durations automatically. Components that use heavy keyframes (modal, drawer, toast, tooltip, skeleton) also detect it and disable their specific animations.


The document surface

Before any component, styles.css claims the body:

:where(html, body) {
    height: 100%;
}

:where(#root) {
    min-height: 100%;
}

:where(#root:has(> :only-child)) {
    height: 100%;
}

body {
    margin: 0;
    background-color: var(--tempest-bg);
    color: var(--tempest-text);
}

That is three promises, and it is worth knowing which is which.

The margin. The user agent gives body an 8px margin. That is not cosmetic next to AppShell, which is min-height: 100dvh: the document then measures 100dvh + 16px, so a page that fits the viewport opens with a vertical scrollbar.

The paint. ThemeProvider writes data-tempest-theme on <html> and the tokens live on :root, but that alone paints nothing — it only declares. Without the rule above, the area behind the shell stays white in the dark theme, framing the content on all four sides, and any element with no background of its own resolves up to a white body.

The height chain. height: 100% is a percentage, and a percentage needs a parent with a resolved height. Without all three links, a percentage-height shell collapses onto its content instead of filling the viewport.

#root only takes the fixed height when it has a single child. A fixed height on #root also fixes the containing block of every child, and a sticky element cannot leave its own containing block: a Navbar placed directly in #root, next to a long <main>, scrolled away after one viewport (#406). With min-height, #root grows with the content and the bar stays at the top. A full-screen shell (Page, Resizable, a <div style="height: 100%"> of yours) is the single child of #root, while a sticky bar always has a sibling — the content below it —, and :only-child is what tells the two cases apart.

Measured in Chromium and Firefox at 1440×900, with scrollY 1261 for the bar (probe using the published components, getBoundingClientRect().top and offsetHeight):

Layout inside #root height: 100% (before) min-height: 100% only current
Navbar + long <main> — bar top −417px 0 0
AppShell with long content — bar top −1261px −1261px 0
short Page, single child 900px 271px 900px
Resizable, single child 900px 24px 900px
<div style="height: 100%">, single child 900px 120px 900px

The AppShell row is a different defect that looked the same: the navbar wrapper was a block exactly as tall as the bar, so the sticky Navbar inside it had nowhere to stick. The wrapper is now display: contents, and the Navbar's own sticky prop decides.

A full-screen shell with a sibling in #root

:only-child counts elements, not in-flow boxes. A position: fixed toast or banner rendered next to the shell in #root makes #root grow with its content again, and a short Page stops filling the viewport (900px → 212px, same measurement). The SDK's overlays portal to body and do not count. For a third-party overlay, wrap the shell and the overlay in a single element, or bring the fixed height back with a rule of your own:

#root { height: 100%; }

In an app with a Navbar directly in #root, that rule brings the #406 defect back.

Switching the display of #root to grid or flex, which would fill the viewport with any number of children, was measured and rejected: a child with max-width: 600px; margin: 0 auto shrinks to its content width (32px), and every child is stretched to the viewport height.

Zero specificity on the height chain

#root is markup this sheet does not draw — the id is your convention, not the SDK's. So the chain goes inside :where(), which adds no specificity: any rule of yours beats it without !important.

#root { height: auto; }

Changing the background is one declaration

body is a 0-0-1 selector. An app that wants a different background — an image, a gradient, a brand colour — overrides it normally:

body { background: linear-gradient(180deg, #0b0d12, #14171f); }

What the reset does not decide

The document's type family. The SDK declares --tempest-font-sans but does not apply it to body: which font the whole page renders in is the app's call, and an SDK that imposed it would cap anyone shipping their own.

body { font-family: var(--tempest-font-sans); }

color-scheme — the surfaces the browser paints

Tokens do not reach everything. The <select> dropdown popup, the scrollbar, the autofill wash, the date picker, the input[type=number] spinner and the default canvas are drawn by the user agent, from a single property:

:root {
    color-scheme: light;
}

[data-tempest-theme="dark"] {
    color-scheme: dark;
}

The SDK ships both. You do not have to do anything — but it is worth knowing they are there, because it is what explains why an <input> with no styling at all inside your app already comes out dark in the dark theme.

Reading prefers-color-scheme is not declaring color-scheme

Two different things with similar names, and confusing them cost 58 releases. ThemeProvider always read the media query to resolve the "system" mode. Declaring the property is what tells the browser which palette to paint the things it draws itself from.

Measured in Chromium with data-tempest-theme="dark" on and the property absent: getComputedStyle(document.documentElement).colorScheme came back "normal", a class-less <select> painted #efefef and a class-less <input> painted #ffffff, both carrying the dark theme's #f1f3f8 text — 1.03:1 and 1.11:1.

Partial scope works

The declaration goes on the attribute selector rather than in a media query, so a dark subtree inside a light page gets dark native controls too:

<aside data-tempest-theme="dark">
    <select><!-- dark popup --></select>
</aside>

The reset and your markup

styles.css ships a modern reset, and one of its rules reaches elements the SDK does not draw:

img, svg, video, canvas, audio, iframe, embed, object {
    display: block;
    max-width: 100%;
}

Making media block-level removes the phantom gap under an inline image, which is why every modern reset has this rule. But a user-agent <button> centres its content with text-align: center, and text-align only reaches inline boxes — so a lone icon inside a button of yours would sit flush against the left edge.

The SDK ships the counterweight alongside it:

:where(button, a, label, summary) > svg:only-child {
    margin-inline: auto;
}

Zero specificity, on purpose

:where() adds no specificity, so any rule of yours beats this one without !important. Centred is the default; a different alignment is one ordinary declaration:

.toolbar button > svg { margin-inline: 0; }

Only for a lone icon

:only-child keeps the rule to the icon-only button. An icon beside a label already lives in a flex row of yours, where margin: auto would shove the text — that case keeps whatever alignment you gave it.

Focus ring

--tempest-focus-ring-color: #0052cc;
--tempest-focus-ring-width: 3px;
--tempest-focus-ring-offset: 2px;

A global :focus-visible applied in reset.css (and, on the scoped path, in scoped.css). Interactive components — Button, interactive Card, Tabs, Pagination — reapply the ring from the same tokens.

The dark theme uses #60a5fa. Neither is the theme's --tempest-primary, and that is deliberate: in light they would be the same colour, so the ring around a primary Button became the fill's own ink separated by 2px — a halo, not a ring. In dark, #3b82f6 drops to 3.73:1 against --tempest-surface-3, and that margin disappears the moment the ring lands on an app surface the SDK never measured against.

The ring colour has to be opaque

Through 0.60.0 these tokens were semitransparent, and that alone is why they failed: a tinted ring has no contrast of its own — it has the contrast of whatever is underneath. Measured, the old value gave 1.60:1 in the light theme and 2.08:1 in dark, against the 3:1 WCAG 2.2 (SC 1.4.11, Non-text Contrast) asks of a non-text indicator.

If you override the token, override it with an opaque colour and check the number.

To customise per subtree (a white-label theme, say):

.my-app {
    /* opaque, and measured against the surfaces the ring can land on */
    --tempest-focus-ring-color: #7c3aed;
}

tokens.css is a prerequisite, not an optional companion

The reset's var() does carry a fallback (currentColor), but it rescues your app's markup, not the SDK's components: they declare the ring with var(--tempest-focus-ring-color) and no fallback — 59 such uses — so with the token undefined those declarations are invalid at computed-value time and drop to outline: none. Measured in Chromium: Button and Input lose the ring entirely.

Load tokens.css whenever you load scoped.css or base.css.

What to measure against

The ring is drawn wherever a focusable element is, and a component nests as deep as the app puts it. Measure your colour against all four surfaces — --tempest-bg, --tempest-surface, --tempest-surface-2, --tempest-surface-3 — not against one. Fixing a single background is how a token passes its own test and fails inside a card.

src/styles/focus-ring.contrast.test.ts does precisely that for the SDK's tokens, in both themes, and fails the build below 3:1 — including if the colour goes back to carrying alpha.


Selected state

--tempest-selected-indicator: #0052cc; /* dark: #60a5fa */

The ink a component uses to say "this one is selected": the active segment of SegmentedControl, the highlighted row of Command, DropdownMenu and ContextMenu.

It exists because a surface cannot do that job. The four surfaces are one short step apart by design:

pair light dark
bg vs surface 1.053:1 1.085:1
bg vs surface-2 1.112:1 1.225:1
bg vs surface-3 1.240:1 1.416:1

Not even the extremes of the ramp reach the 3:1 WCAG 2.2 SC 1.4.11 asks of a non-text indicator — and widening the step to make one would ruin every card stacked on a background.

A brand tint does not fix it either — which is why this token is not --tempest-primary-soft

The intuitive answer is to mark selection with --tempest-primary-soft, which is what ToggleGroup, ListTile, TreeView and NavigationRail do. Measured across the twelve brands of the theme guard, --tempest-primary-soft lands between 1.03:1 and 1.61:1 against --tempest-bg in both schemes: it differs in hue, and WCAG contrast does not count hue.

So the indicator is a saturated ink drawn on top of the tint — a border on SegmentedControl, a left bar in the three menus — rather than a swapped background. The tint stays; it reinforces the signal instead of being it.

A theme generated by createTheme emits this token the same way it derives the focus ring: walk the brand ramp until the ink clears 3:1 against all four surfaces, in both schemes. Unlike the ring, it never takes an alpha — focusRingAlpha does not reach it, because a translucent indicator has the contrast of whatever it composites over, which is the defect this token ends.

.my-app {
    /* opaque, and measured against the four surfaces */
    --tempest-selected-indicator: #7c3aed;
}

The guard

src/styles/selected-indicator.contrast.test.ts measures the token against the four surfaces in both themes and also pins the numbers above — so if "just use surface-2, that is two steps" comes back as a proposal, the assertion that fails it is already written. src/styles/selected-indicator.usage.test.ts keeps the four components drawing the state with the token, and every other component off it.


Z-index

--tempest-z-base: 0 --tempest-z-raised: 10 --tempest-z-dropdown: 1000 --tempest-z-sticky: 1020
  --tempest-z-overlay: 1050 --tempest-z-modal: 1100 --tempest-z-popover: 1150
  --tempest-z-toast: 1200 --tempest-z-tooltip: 1300;

Density — data-tempest-density

An attribute applied to any element (usually <html> or <body>) adjusts the height, padding, font-size, and radius of every control in the subtree.

<html data-tempest-density="compact"></html>

Values: compact | comfortable (default) | spacious.

Controlled tokens:

--tempest-control-height-xs..xl
--tempest-control-padding-xs..xl
--tempest-control-font-xs..xl
--tempest-control-radius
--tempest-control-gap
--tempest-control-caret-offset
--tempest-control-caret-size

Button, Input, Select, and Textarea already read these tokens — just swap the attribute on the root and everything resizes together.


Dark theme — data-tempest-theme

<html data-tempest-theme="dark"></html>

An attribute applied to any element enables the dark theme only in that subtree. Color tokens (primary scale, neutrals, status, focus ring, shadow) are all overridden.

Use it together with <ThemeProvider> (tempest-react-sdk/theme) for persistence + flash prevention.

Use data-tempest-theme=\"dark\", not class=\"dark\"

The SDK's dark mode toggles on the data-tempest-theme attribute, never on a dark class. This lets you scope the dark theme to a specific subtree instead of the whole document — something the class convention can't do.


Components — available variants

Button

<Button variant="primary | secondary | danger | success | ghost | soft | outline | link" />
<Button size="xs | sm | md | lg | xl" />
<Button iconOnly aria-label="..." />
<Button pill />
<Button loading />

Badge

<Badge
  variant="neutral | primary | success | warning | danger | info"
  appearance="soft | solid | outline"
  size="sm | md | lg"
  shape="pill | square"
  dot
/>

Alert

<Alert variant="neutral | info | success | warning | danger"
       appearance="soft | solid | outline"
       title="..."
       description="..."
       icon={<Icon />}
       onClose={() => ...} />

Card

<Card elevation="flat | default | raised | elevated"
      interactive
      title="..."
      actions={...}
      footer={...} />

Input

<Input size="sm | md | lg" />

Spinner

<Spinner size="xs | sm | md | lg | xl" />

Divider

<Divider
  orientation="horizontal | vertical"
  variant="solid | dashed"
  label="OR"
  align="start | center | end"
/>

Kbd

<Kbd size="sm | md | lg">Ctrl</Kbd>

Importing tokens in CSS-in-JS

The tempest_ prefix avoids collisions

The classes generated by the CSS Modules come out prefixed with tempest_, so they never collide with your app's CSS or with Tailwind/Stitches/Linaria running side by side. You only interact with the --tempest-* tokens — you never need to know the class names.

CSS Modules is the SDK's only styling strategy

Components are styled with CSS Modules plus --tempest-* tokens, full stop. There is no headless mode and no class hook (data-tempest-classname) for Tailwind/Stitches/Linaria to take over the styling — and none is planned. Keeping two styling paths would double every component's surface and dilute the tokens.

What you can do: run your favourite utility framework side by side in the rest of the app, read the SDK's tokens with var(--tempest-*), and customise the SDK by overriding those tokens on :root.

Since the tokens are CSS Custom Properties, any solution (styled-components, emotion, vanilla-extract, Tailwind arbitrary values) reads them with var(--tempest-*):

import styled from "styled-components";

const Card = styled.div`
  background: var(--tempest-bg);
  border: 1px solid var(--tempest-border);
  border-radius: var(--tempest-radius-lg);
  padding: var(--tempest-space-5);
  box-shadow: var(--tempest-shadow-sm);
`;

Tailwind via theme.extend.colors:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        tempest: {
          primary: "var(--tempest-primary)",
          bg: "var(--tempest-bg)",
          border: "var(--tempest-border)",
        },
      },
    },
  },
};

Opt-in utility layer — utilities.css

Components are styled with CSS Modules, which solves the inside of them. What was left for the app was the around: the page shell, a two-column form, a row of actions, a card, a region that scrolls sideways. Every app rewrote that CSS.

utilities.css is that layer, written only in --tempest-* tokens — so it follows the theme, including whatever createTheme emits and dark mode.

// src/main.tsx
import "tempest-react-sdk/styles.css";
import "tempest-react-sdk/utilities.css"; // opt-in

Why opt-in and not part of styles.css

It ships ~50 global class names. An app that already has its own layout system should not pay for them, and injecting global classes into someone's page uninvited is rude. Cost if you opt in: 1.13 KB brotli.

This is not a Tailwind, and it is not becoming one

The layer holds a handful of layout primitives — there is no (and no plan for) p-4 mt-2 text-sm bg-blue-500 for every possible value. The consolidated decision still holds: CSS Modules + tokens is the components' styling strategy. This is a tool for the app's code, not a second way to style the SDK.

Layout

Class What it does Tune with
.tempest-container Centers, caps the width, applies gutters that respect the safe area --tempest-container-width, --tempest-container-gutter
.tempest-stack Vertical flow with a single gap --tempest-stack-gap
.tempest-cluster Horizontal group that wraps instead of overflowing --tempest-cluster-gap
.tempest-row Horizontal group that does not wrap (toolbars, inline fields) --tempest-row-gap
.tempest-center Centers the child on both axes —
.tempest-spread Pushes first and last child apart (title ↔ actions) --tempest-row-gap
.tempest-grid-auto Responsive card grid with no media query (auto-fill + minmax) --tempest-grid-min, --tempest-grid-gap
.tempest-sidebar-layout Sidebar + content; collapses to one column below 768px --tempest-sidebar-width, --tempest-sidebar-gap
.tempest-form-grid Two-column form that collapses below 640px --tempest-form-columns, --tempest-form-gap
.tempest-form-span Field taking the full row of the form grid —
.tempest-fill Takes the remaining flex space (with min-width: 0, so truncation works) —
.tempest-fixed Never shrinks below its content (an icon button next to a growing field) —

Spacing, text, surfaces, scrolling

Group Classes
Gap .tempest-gap-{0,1,2,3,4,5,6,8,10,12}
Padding .tempest-pad-{0,2,3,4,6,8}, .tempest-pad-block, .tempest-pad-inline
Text .tempest-truncate, .tempest-clamp-{2,3,4}, .tempest-text-{muted,subtle}, .tempest-text-{xs,sm,base,lg,xl,2xl}, .tempest-weight-{medium,semibold,bold}, .tempest-numeric
Surfaces .tempest-card, .tempest-panel, .tempest-inset, .tempest-divider
Scrolling .tempest-scroll-x, .tempest-scroll-y
Media .tempest-aspect-video, .tempest-aspect-square
Misc .tempest-visually-hidden, .tempest-no-select, .tempest-busy

.tempest-numeric exists for one specific reason

font-variant-numeric: tabular-nums stops a column of numbers from dancing as values change (proportional digits have different widths). Use it on value tables, live counters and Stat.

.tempest-scroll-x around a wide table

Without it, a wide table makes the page scroll horizontally — the most common mobile layout defect there is. With it, the scrolling stays inside the region.

A whole page, assembled

export function UsersPage() {
  return (
    <div className="tempest-container tempest-page">
      <header className="tempest-page-header">
        <div>
          <h1 className="tempest-page-title">Users</h1>
          <p className="tempest-page-subtitle">142 active · 8 pending invites</p>
        </div>
        <div className="tempest-cluster">
          <Button variant="secondary">Export</Button>
          <Button>Invite</Button>
        </div>
      </header>

      <div className="tempest-toolbar tempest-toolbar-sticky">
        <SearchBar className="tempest-fill" placeholder="Search by name or email" />
        <Select className="tempest-fixed" options={roles} />
      </div>

      <div className="tempest-card tempest-scroll-x">
        <DataTable data={users} columns={columns} />
      </div>

      <div className="tempest-grid-auto" style={{ "--tempest-grid-min": "220px" } as React.CSSProperties}>
        <Stat label="Active" value={142} />
        <Stat label="Invited" value={8} />
        <Stat label="Blocked" value={3} />
      </div>
    </div>
  );
}

Note the style={{ "--tempest-grid-min": "220px" }}: the local hooks are custom properties, so you tune per instance without writing CSS or inventing a class variant.

Widget dashboard

The layer ships a slice for this, and what sets it apart from a plain grid is that the columns react to the container width, not the viewport.

import "tempest-react-sdk/utilities.css";

export function OperationsPage() {
  return (
    <div className="tempest-container tempest-page">
      <header className="tempest-page-header">
        <div>
          <h1 className="tempest-page-title">Operations</h1>
          <p className="tempest-page-subtitle">Last 30 days</p>
        </div>
        <Badge variant="success">live</Badge>
      </header>

      {/* Tile row: fits as many as it can, no spans */}
      <div className="tempest-stat-row">
        <div className="tempest-widget-frame">
          <span className="tempest-text-muted tempest-text-xs">Orders</span>
          <strong className="tempest-text-2xl tempest-numeric">1,284</strong>
        </div>
        {/* … */}
      </div>

      {/* 12-column grid, spans keyed on container queries */}
      <div className="tempest-dashboard">
        <section className="tempest-widget tempest-widget-two-thirds">
          <div className="tempest-widget-frame">
            <div className="tempest-widget-header">
              <h2 className="tempest-widget-title">Sales per day</h2>
              <span className="tempest-text-subtle tempest-text-xs">12 days</span>
            </div>
            <div className="tempest-widget-body">
              <Sparkline data={sales} width={320} height={72} label="Sales per day" />
            </div>
          </div>
        </section>

        <section className="tempest-widget tempest-widget-third">{/* … */}</section>
        <section className="tempest-widget tempest-widget-half">{/* … */}</section>
        <section className="tempest-widget tempest-widget-half">{/* … */}</section>
      </div>
    </div>
  );
}
Class What it does
.tempest-dashboard a 12-column grid and a size container (container-type: inline-size)
.tempest-widget full width by default — the state a widget spends most of its life in
.tempest-widget-half · -third · -quarter · -two-thirds spans that open at 40rem and 64rem of container
.tempest-widget-tall grid-row: span 2 — a chart beside a stack of tiles
.tempest-stat-row tile row with auto-fit, no spans. Tune with --tempest-stat-min
.tempest-widget-frame · -header · -title · -body the widget's own frame

Hooks: --tempest-dashboard-columns (12), --tempest-dashboard-gap, --tempest-widget-padding, --tempest-stat-min.

The columns belong to the container, not the viewport — and that is the point

Measured in a browser at a 1360px viewport: the same dashboard inside a 440px panel renders as a single column; at 660px the -third and the -half share a row (span 6); at 1060px -two-thirds takes span 8 beside -third at span 4, and the two halves split the next row.

A media query would give the 440px panel the desktop span, and every widget in it would be one column of squashed text. Same reason Masonry observes its container.

width: 100% on .tempest-page is not decoration

Dropped inside a flex row — a preview pane, a split view — a page container is a flex item and sizes to its content: the dashboard collapsed to about 200px while its parent had 500. Only a browser shows that; in normal flow the declaration changes nothing. Found exactly that way, building this recipe in the gallery.

min-height: 0 on -body is what lets a chart fit

A grid child defaults to min-height: auto, so a canvas reporting a tall intrinsic size pushes the row instead of fitting it — and the dashboard grows a scrollbar nobody asked for.

A user-resizable widget is a different thing

Dragging a widget's edge fights the grid: the tracks come from the grid, and a pixel width from a drag cannot coexist with that. If you need it, use Resizable in a free-form area, or store the chosen span per widget and apply the matching class — which is the version that survives a reload and fits in a URL.

Two-column form

<Form className="tempest-form-grid" onSubmit={form.handleSubmit(onSubmit)}>
  <FormField name="name" label="Name" required><Input /></FormField>
  <FormField name="email" label="Email" required><Input type="email" /></FormField>
  <FormField name="cpf" label="CPF"><CPFInput /></FormField>
  <FormField name="phone" label="Phone"><PhoneInput /></FormField>

  <FormField name="notes" label="Notes" className="tempest-form-span">
    <Textarea rows={4} />
  </FormField>

  <FormActions align="end" className="tempest-form-span">
    <Button type="submit">Save</Button>
  </FormActions>
</Form>

One column on a phone, two from 640px up, and .tempest-form-span for whatever takes the full row.


Responsive — mobile / tablet / desktop

Breakpoints

Token Pixels Expected device
--tempest-bp-xs 480px Small phones
--tempest-bp-sm 640px Large phones
--tempest-bp-md 768px Tablets
--tempest-bp-lg 1024px Laptops
--tempest-bp-xl 1280px Default desktop
--tempest-bp-2xl 1536px Ultrawide

useBreakpoint() / <Show> / <Hide> convention:

  • mobile = < md (< 768px)
  • tablet = md..lg-1 (768..1023px)
  • desktop = >= lg (>= 1024px)

useBreakpoint() hook

import { useBreakpoint } from "tempest-react-sdk";

const bp = useBreakpoint();
bp.current; // "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
bp.width; // pixels (0 on SSR)
bp.above("md"); // boolean
bp.below("lg"); // boolean
bp.isMobile; // < md
bp.isTablet; // md..lg-1
bp.isDesktop; // >= lg

SSR-safe — on the server it returns xs / width: 0, updating on mount.

<Show> / <Hide> components

<Show above="md">Desktop nav</Show>
<Show below="md">Mobile menu</Show>
<Show only="xl">Wide-only banner</Show>
<Show only={["md", "lg"]}>Tablet + laptop</Show>

<Hide above="lg">Hide on desktop</Hide>

Utility classes (CSS-only, no JS)

<div class="tempest-hide-mobile">desktop only</div>
<div class="tempest-show-only-mobile">mobile only</div>
<div class="tempest-hide-tablet">hide on tablets</div>
<div class="tempest-show-only-touch">touch devices only</div>
<div class="tempest-hide-print">don't print</div>

Responsive components — props

<Container> — automatic responsive padding

space-4 mobile / space-6 tablet / space-8 desktop.

<Stack> / <Grid> — props accept an object

<Stack direction={{ mobile: "vertical", desktop: "horizontal" }} gap={{ mobile: 2, desktop: 4 }} />

<Grid columns={{ mobile: 1, tablet: 2, desktop: 3 }} gap={4} />
<Modal size="2xl" />                  // 1280px
<Modal size="3xl" />                  // 1440px
<Modal fullscreen />                  // fill viewport
<Modal fullscreenOnMobile />          // auto-fullscreen < 640px

Internal padding and radius already shrink below 640px.

<Drawer> — mobilePlacement + showHandle

// desktop: right drawer; mobile: bottom-sheet
<Drawer placement="right" mobilePlacement="bottom" showHandle />

<Table> — priority + stackOnMobile

<Table
  stackOnMobile
  columns={[
    { key: "name", header: "Name" }, // always visible
    { key: "email", header: "E-mail", priority: "tablet" }, // hidden < 768px
    { key: "role", header: "Role", priority: "desktop" }, // hidden < 1024px
  ]}
  data={users}
/>

<ToastProvider> — position

<ToastProvider position="top-right" />        // default
<ToastProvider position="bottom-center" />    // mobile-friendly default

On screens < 480px, the container automatically stretches left: 0; right: 0.

Touch targets

  • data-tempest-density="touch" — forces a 44px minimum height on every control.
  • @media (pointer: coarse) applies an auto-bump on xs/sm/md when the user is on a touch device (unless density="compact" is explicit).
  • Button iconOnly size xs/sm gains an invisible 8px hit-slop on all sides on pointer coarse.

Safe-area (iOS notch / Android gestures)

Available tokens:

--tempest-safe-area-top
--tempest-safe-area-right
--tempest-safe-area-bottom
--tempest-safe-area-left

Toast, the Modal overlay padding, and Drawer already consume them automatically. Remember to include this in the HTML:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

Dynamic viewport (iOS Safari address bar bug)

Modal and Drawer use dvh with a vh fallback. Apps that need full height can do the same:

.app {
  min-height: 100vh;
  min-height: 100dvh;
}

Fluid type

For headings that scale with the viewport:

.hero-title {
  font-size: var(--tempest-text-fluid-5xl); /* clamp(32px, 24px + 4vw, 72px) */
}

Tokens: --tempest-text-fluid-sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl.

Hover-only effects

transform / box-shadow hover effects (interactive Card lift, Button elevation) sit behind @media (hover: hover) and (pointer: fine) — they don't fire on a mobile tap.

Print

Everything is bundled in print.css:

  • Modal, Drawer, Toast, Tooltip are hidden.
  • Grayscale background, cards get page-break-inside: avoid.
  • Links get their (href) appended next to them.

Use the tempest-hide-print class to hide your own elements.


Token versioning policy

Tokens are a public API. Changes break consumer apps. Policy:

  • Additions (new tokens) — minor bump.
  • Renames / removals — major bump. Old tokens stay as deprecated aliases for at least 1 minor before removal.
  • Value changes that visibly affect appearance (primary color, default radius, font stack) — minor bump + a changelog note.

Recap

  • Import tempest-react-sdk/styles.css once; theme by overriding --tempest-* tokens on :root (or a subtree).
  • Dark mode toggles via data-tempest-theme="dark"; density via data-tempest-density — both scopable to any subtree.
  • CSS Module classes come out prefixed with tempest_, with no collision with your app's CSS or with Tailwind/Stitches/Linaria.
  • Tokens are a public API under semver — additions bump minor, renames/removals bump major.