Integration · Setup
React tokens Light, dark and system, from one CSS export
The colors stay in CSS. React only says which theme is on: a provider sets data-theme on the page, the token studio’s export does the rest, and a small script picks the theme before the first paint. The example was built with React 19.3.1 and esbuild 0.28.2, and checked in both themes.
- Version
- React 19.3.1
- Needs
- Node.js and npm; React 19.3.1
- Cost
- Free, nothing to sign up for
- Checked
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
-
Download the tokens
tokens.cssis the token studio’s CSS export: every role for the light theme on:rootand[data-theme="light"], and for the dark theme on[data-theme="dark"]./* 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); /* … */ } -
Pick the theme before the first paint
Load
theme-init.jswith a plain script tag in the head. It reads the saved choice, or the system setting, and setsdata-themebefore anything is drawn, so a dark-mode reader never sees a light flash.// 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"; })(); -
Add the provider
ThemeProviderholds the choice: System follows the operating system as it changes, Light and Dark are kept in localStorage. It never handles a color.// 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); -
Style components with the roles
Write CSS against the roles, such as
var(--color-text)andvar(--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 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> ); }/* 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; } }// 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> ); } -
Install and build
These are the exact versions the example was built and checked with.
npm run buildbundlesmain.jsxintoexample.js; openexample.html.{ "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" } }npm install npm run buildYou 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.
Files
Every file of the example
| File | What it is |
|---|---|
| tokens.css | The token studio’s CSS export: both themes. |
| theme-init.js | Sets the theme before the first paint. |
| theme.jsx | The provider and the useTheme hook. |
| components.jsx | A button, a field with its error, a status message and the theme switch. |
| components.css | Their styles, from the roles only. |
| App.jsx | A settings form that uses them. |
| main.jsx | The entry point esbuild bundles. |
| example.html | The page that loads them, in this order. |
package.json | The 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.
| Check | How to check it | Light theme | Dark theme |
|---|---|---|---|
| Text contrast | Every 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 |
| Boundaries | The 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 focus | Tab 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 size | Buttons 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 colors | With 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 rules | Run axe or Lighthouse in each theme. | 0 violations Passes | 0 violations Passes |
The theme and the states
| Behavior | Result |
|---|---|
| Match the system follows the operating system, both ways, without a reload | Passes |
| A chosen theme is back after a reload, before the first paint | Passes |
| The field in error has aria-invalid, and aria-describedby names its error | Passes |
| The confirmation lands in a status region that was in the page first | Passes |
Limits
What it does not do
With server rendering, set
data-themeon thehtmlelement from a cookie, or keeptheme-init.jsin 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
- The same tokens in a Tailwind starter
- Open the token studio
- An accessible theme switcher, as a pattern
- Dark mode and adaptive color, explained
Other integrations
- Browser extensionCheck and fix contrast on the page in front of you.
- VS CodeUnderline low contrast in your stylesheets and fix it in place.
- REST APIConvert colors and check pairs from a server or a script.
- Design tokensExport one token system to Tailwind, CSS or Figma.
- Contrast embedPut a contrast checker on your own page.
- Tailwind themeStart a Tailwind v4 project with accessible components.
- Figma variablesTurn Figma variables into CSS tokens.
- Contrast in CICheck your color pairs on every pull request.
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.
- React: createContextreact.dev
- React: useIdreact.dev
- esbuild: Getting startedesbuild.github.io
- MDN: prefers-color-schemedeveloper.mozilla.org
- Understanding WCAG 2.2: Status Messageswww.w3.org
- Understanding WCAG 2.2: Non-text Contrastwww.w3.org
- axe-coregithub.com
Found a mistake? Write to us and we will correct it.