Before you start

What you need

  • A React app, or the example files below. They were built with React 19.3.1 on Node.js 24.11.0.
  • A bundler that compiles JSX. The example uses esbuild 0.28.2; any bundler that compiles JSX will do.
  • Nothing from this site to install: the tokens are one CSS file.

Setup

Step by step

  1. Download the tokens

    tokens.css is the token studio’s CSS export: every role for the light theme on :root and [data-theme="light"], and for the dark theme on [data-theme="dark"].

    tokens.css

    /* Auric Artisan design tokens · both themes */
    :root, [data-theme="light"] {
      --color-brand-50: #F2F4FF;
      --color-brand-100: #E6EAFF;
      --color-brand-200: #CDD5FF;
      --color-brand-300: #ADB8FF;
      --color-brand-400: #8B96FF;
      /* … */
    }
    
    [data-theme="dark"] {
      --color-canvas: var(--color-neutral-1000);
      --color-surface: var(--color-neutral-950);
      --color-surface-raised: var(--color-neutral-900);
      /* … */
    }

    Download tokens.css

  2. Pick the theme before the first paint

    Load theme-init.js with a plain script tag in the head. It reads the saved choice, or the system setting, and sets data-theme before anything is drawn, so a dark-mode reader never sees a light flash.

    theme-init.js

    // Sets the theme before the first paint, so a dark-mode reader never sees a light flash.
    // From https://auricartisan.com/integrations/react-tokens/ - CC0 1.0.
    // Load it with a plain <script src> in <head>, before the stylesheets apply.
    (function () {
        var choice = null;
        try { choice = localStorage.getItem("theme"); } catch (e) { /* storage can be blocked */ }
        var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
        document.documentElement.dataset.theme = choice === "light" || choice === "dark" ? choice : dark ? "dark" : "light";
    })();
    
  3. Add the provider

    ThemeProvider holds the choice: System follows the operating system as it changes, Light and Dark are kept in localStorage. It never handles a color.

    theme.jsx

    // The theme state: "system" follows the operating system, "light" and "dark" are the reader's choice.
    // From https://auricartisan.com/integrations/react-tokens/ - CC0 1.0.
    // The colors are never in JavaScript: this only sets data-theme on <html>, and tokens.css does the rest.
    import { createContext, useContext, useEffect, useState } from "react";
    
    const KEY = "theme";
    const query = "(prefers-color-scheme: dark)";
    const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
    const write = (v) => { try { if (v === "system") localStorage.removeItem(KEY); else localStorage.setItem(KEY, v); } catch { /* blocked */ } };
    
    const ThemeContext = createContext({ choice: "system", theme: "light", setChoice: () => {} });
    
    export function ThemeProvider({ children }) {
        const [choice, setChoice] = useState(() => (read() === "light" || read() === "dark" ? read() : "system"));
        const [system, setSystem] = useState(() => (window.matchMedia(query).matches ? "dark" : "light"));
        useEffect(() => {
            const mq = window.matchMedia(query);
            const follow = () => setSystem(mq.matches ? "dark" : "light");
            mq.addEventListener("change", follow);
            return () => mq.removeEventListener("change", follow);
        }, []);
        const theme = choice === "system" ? system : choice;
        useEffect(() => {
            document.documentElement.dataset.theme = theme;
            write(choice);
        }, [theme, choice]);
        return <ThemeContext.Provider value={{ choice, theme, setChoice }}>{children}</ThemeContext.Provider>;
    }
    
    export const useTheme = () => useContext(ThemeContext);
    
  4. Style components with the roles

    Write CSS against the roles, such as var(--color-text) and var(--color-danger-text), never against a hex value, so every component follows the theme. Each state has a word and an icon as well as a color.

    components.jsx

    // Components that take every color from the token roles in tokens.css.
    // From https://auricartisan.com/integrations/react-tokens/ - CC0 1.0.
    // Each state has a word and an icon as well as a color, and every control has a visible focus ring.
    import { useId } from "react";
    import { useTheme } from "./theme.jsx";
    
    const ICONS = {
        success: <path d="M4 10.5 8 14.5 16 6.5" />,
        warning: <><path d="M10 3 18 17H2Z" /><path d="M10 8v4M10 14.5v.5" /></>,
        danger: <path d="M5 5l10 10M15 5 5 15" />,
        info: <><circle cx="10" cy="10" r="7.5" /><path d="M10 9v5M10 6v.5" /></>,
    };
    const WORDS = { success: "Success", warning: "Warning", danger: "Error", info: "Note" };
    
    export function Icon({ tone }) {
        return (
            <svg className="icon" viewBox="0 0 20 20" aria-hidden="true" focusable="false">{ICONS[tone]}</svg>
        );
    }
    
    export function Button({ variant = "primary", ...props }) {
        return <button type="button" className={`button button--${variant}`} {...props} />;
    }
    
    /** A message announced without moving focus: role="status", rendered empty first. A message that is
        there from the start is not news, so live={false} leaves the role off. */
    export function StatusMessage({ tone, children, live = true }) {
        return (
            <div className={`message message--${tone}`} role={live ? (tone === "danger" ? "alert" : "status") : undefined}>
                {children ? <><Icon tone={tone} /><p><strong>{WORDS[tone]}:</strong> {children}</p></> : null}
            </div>
        );
    }
    
    export function Field({ label, error, hint, ...props }) {
        const id = useId();
        const describedBy = [hint && `${id}-hint`, error && `${id}-error`].filter(Boolean).join(" ") || undefined;
        return (
            <div className="field">
                <label htmlFor={id}>{label}</label>
                {hint ? <p className="field__hint" id={`${id}-hint`}>{hint}</p> : null}
                <input id={id} aria-invalid={error ? "true" : undefined} aria-describedby={describedBy} {...props} />
                {error ? <p className="field__error" id={`${id}-error`}><Icon tone="danger" />{error}</p> : null}
            </div>
        );
    }
    
    export function ThemeSwitch() {
        const { choice, setChoice } = useTheme();
        return (
            <fieldset className="theme-switch">
                <legend>Theme</legend>
                {["system", "light", "dark"].map((value) => (
                    <label key={value}>
                        <input type="radio" name="theme" value={value} checked={choice === value} onChange={() => setChoice(value)} />
                        <span>{value === "system" ? "Match the system" : value === "light" ? "Light" : "Dark"}</span>
                    </label>
                ))}
            </fieldset>
        );
    }
    

    components.css

    /* The components' styles, from the token roles only: no color is typed here.
       From https://auricartisan.com/integrations/react-tokens/ - CC0 1.0. */
    *, *::before, *::after { box-sizing: border-box; }
    body {
        margin: 0;
        padding: 24px 16px;
        background: var(--color-canvas);
        color: var(--color-text);
        font: 16px/1.55 system-ui, sans-serif;
    }
    .card {
        max-width: 560px;
        margin: 0 auto;
        padding: 24px;
        border: 1px solid var(--color-border);
        border-radius: 12px;
        background: var(--color-surface);
    }
    h1 { margin: 0 0 16px; font-size: 24px; }
    h2 { margin: 28px 0 12px; font-size: 18px; }
    :focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; }
    
    .theme-switch { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0 0 20px; padding: 12px 14px; border: 1px solid var(--color-border); border-radius: 8px; }
    .theme-switch legend { padding: 0 4px; font-weight: 600; }
    .theme-switch label { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; }
    .theme-switch input { width: 20px; height: 20px; margin: 0; accent-color: var(--color-primary); }
    
    .field { display: flex; flex-direction: column; gap: 6px; }
    .field label { font-weight: 600; }
    .field__hint { margin: 0; color: var(--color-text-muted); font-size: 14px; }
    .field input { min-height: 44px; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 8px; background: var(--color-surface); color: var(--color-text); font: inherit; }
    .field input[aria-invalid="true"] { border: 2px solid var(--color-danger-text); }
    .field__error { display: flex; align-items: center; gap: 6px; margin: 0; color: var(--color-danger-text); font-size: 14px; font-weight: 600; }
    
    .actions { display: flex; gap: 10px; margin-top: 18px; }
    .button { min-height: 44px; padding: 8px 18px; border-radius: 8px; font: 600 15px system-ui, sans-serif; cursor: pointer; }
    .button--primary { border: 1px solid var(--color-primary); background: var(--color-primary); color: var(--color-on-primary); }
    .button--primary:hover { border-color: var(--color-primary-hover); background: var(--color-primary-hover); }
    .button--secondary { border: 1px solid var(--color-border); background: transparent; color: var(--color-text); }
    
    .message { display: flex; align-items: flex-start; gap: 10px; margin-top: 12px; border-radius: 8px; }
    .message:not(:empty) { padding: 10px 12px; border: 1px solid currentColor; }
    .message p { margin: 0; color: var(--color-text); }
    .message--success { background: var(--color-success-surface); color: var(--color-success-text); }
    .message--warning { background: var(--color-warning-surface); color: var(--color-warning-text); }
    .message--danger { background: var(--color-danger-surface); color: var(--color-danger-text); }
    .message--info { background: var(--color-info-surface); color: var(--color-info-text); }
    .message:empty { margin: 0; background: none; }
    .states { display: flex; flex-direction: column; }
    .icon { flex: none; width: 20px; height: 20px; margin-top: 2px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
    
    @media (forced-colors: active) {
        .button--primary { border-color: ButtonText; }
    }
    

    App.jsx

    // A settings form with every state the tokens style.
    // From https://auricartisan.com/integrations/react-tokens/ - CC0 1.0.
    import { useState } from "react";
    import { Button, Field, StatusMessage, ThemeSwitch } from "./components.jsx";
    
    export function App() {
        const [email, setEmail] = useState("asha.rao@mail");
        const [error, setError] = useState("");
        const [saved, setSaved] = useState("");
        const save = () => {
            const ok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
            setError(ok ? "" : "Enter an email address with a domain, such as [email protected].");
            setSaved(ok ? "Your settings are saved." : "");
        };
        return (
            <main className="card">
                <h1>Account settings</h1>
                <ThemeSwitch />
                <Field label="Email" hint="We send receipts here." type="email" autoComplete="email" value={email} error={error} onChange={(e) => setEmail(e.target.value)} />
                <div className="actions">
                    <Button onClick={save}>Save</Button>
                    <Button variant="secondary" onClick={() => { setError(""); setSaved(""); }}>Cancel</Button>
                </div>
                <StatusMessage tone="success">{saved}</StatusMessage>
                <h2>Every state</h2>
                <div className="states">
                    <StatusMessage tone="success" live={false}>Your payment went through.</StatusMessage>
                    <StatusMessage tone="warning" live={false}>Your card expires next month.</StatusMessage>
                    <StatusMessage tone="info" live={false}>Prices include tax.</StatusMessage>
                </div>
            </main>
        );
    }
    
  5. Install and build

    These are the exact versions the example was built and checked with. npm run build bundles main.jsx into example.js; open example.html.

    package.json

    {
      "name": "react-color-tokens",
      "private": true,
      "scripts": {
        "build": "esbuild main.jsx --bundle --minify --jsx=automatic --outfile=example.js"
      },
      "dependencies": {
        "react": "19.3.1",
        "react-dom": "19.3.1"
      },
      "devDependencies": {
        "esbuild": "0.28.2"
      }
    }
    

    Terminal

    npm install
    npm run build

    You should see: the example below, in whichever theme your system uses.

The example

The built example

Choose a theme in it, and reload: the choice stays. Save with the email as it is to see the error, then fix it to see the confirmation.

The files below, built with React 19.3.1 and esbuild 0.28.2. The example is in English, as the code is. Open the built example in its own tab

Files

Every file of the example

The example’s files and what each does
FileWhat it is
tokens.cssThe token studio’s CSS export: both themes.
theme-init.jsSets the theme before the first paint.
theme.jsxThe provider and the useTheme hook.
components.jsxA button, a field with its error, a status message and the theme switch.
components.cssTheir styles, from the roles only.
App.jsxA settings form that uses them.
main.jsxThe entry point esbuild bundles.
example.htmlThe page that loads them, in this order.
package.jsonThe tested versions and the build script, to copy from step 5.

Verification

What was checked on the build

Run the same checks on your app after you change anything. The last two columns are what each check found on the built example.

The checks, with what each found on the built example
CheckHow to check itLight themeDark theme
Text contrastEvery text color on its background reaches 4.5:1, the hint and the error included.

7.09:1 at its lowest

Passes

9.65:1 at its lowest

Passes
BoundariesThe field’s border and the secondary button’s border reach 3:1 against the surface.

3.62:1 at its lowest

Passes

4.98:1 at its lowest

Passes
Keyboard focusTab through the form. Every stop shows a 2-pixel outline in the ring color, at 3:1 or more against what is behind it.

4 of 4 stops; outline 7.90:1

Passes

4 of 4 stops; outline 9.53:1

Passes
Target sizeButtons and the field are 44 pixels tall; each radio’s label, which selects it, is at least 24.

24 px at the smallest

Passes

24 px at the smallest

Passes
Forced colorsWith forced colors on, every button and the field keep a visible border.

3 of 3 keep a border

Passes

3 of 3 keep a border

Passes
Automated rulesRun axe or Lighthouse in each theme.

0 violations

Passes

0 violations

Passes

The theme and the states

How the example behaves, as checked
BehaviorResult
Match the system follows the operating system, both ways, without a reloadPasses
A chosen theme is back after a reload, before the first paintPasses
The field in error has aria-invalid, and aria-describedby names its errorPasses
The confirmation lands in a status region that was in the page firstPasses

Limits

What it does not do

  • With server rendering, set data-theme on the html element from a cookie, or keep theme-init.js in the head, or the first paint can be the wrong theme.

  • Vue and Svelte need only the provider rewritten: the tokens and the CSS are the same, so there is no separate guide.
  • Automated checks cannot judge meaning. The example has not been tested with a screen reader.
  • The example covers color, focus and states. Layout, routing and data are yours.

Privacy and access

What stays in the browser

  • The theme choice is kept in localStorage, under the key theme. Nothing is sent anywhere.

Go further

The full reference

Checked against

Where these facts come from

Versions, requirements and examples on this page were checked on . The example was built from the files on this page and the token studio’s CSS export with React 19.3.1, React DOM 19.3.1 and esbuild 0.28.2 on Node.js 24.11.0, then checked in Chromium with axe 4.12.1 in both themes; contrast, focus, target size and forced colors were measured on the page, and the system setting, the reload, the error and the confirmation were tried.

Found a mistake? Write to us and we will correct it.