Skip to main content
Copy for LLM

Theming

WireKit uses a comprehensive design token system built on CSS variables. Every visual property — colors, radius, shadows, typography, motion, sizing — can be customized by overriding tokens in your app.css.

How It Works

WireKit ships with a default theme defined in wirekit.css, loaded separately via the @wirekitStyles Blade directive. All components reference these tokens exclusively — no hardcoded colors, sizes, or durations.

Override any token in your app.css — the overrides cascade on top of the separately loaded wirekit.css. Use a plain :root {} block; the --color-wk-* / --radius-wk-* / --shadow-wk-* tokens are CSS custom properties, NOT Tailwind theme tokens, so they don't need an @theme {} wrapper:

@import 'tailwindcss';

/* Override WireKit tokens */
:root {
    --color-wk-accent: var(--color-blue-600);
    --radius-wk: 1rem;
    --shadow-wk-sm: 0 0 0 0 transparent;
}
.dark {
    --color-wk-accent: var(--color-blue-400);
}

To switch a shadow off, give it 0 0 0 0 transparent rather than none. A button or a field writes its shadow and its focus ring into one box-shadow, and none is valid there only on its own: inside that list it makes the whole declaration invalid, and the focus ring goes with the shadow.

:root {} vs @theme {} vs @layer base {} — what each one does:

All three set CSS custom properties; pick the one that matches your intent.

  • :root {} — recommended for WireKit token overrides. Plain CSS, no Tailwind compiler involvement. The --color-wk-* / --radius-wk-* / --shadow-wk-* tokens are custom properties consumed directly by var() calls inside Blade templates, so a :root override reaches them straight away — nothing has to survive a compiler step, and nothing sits in a cascade layer.
  • @theme {} — Tailwind v4-specific, and NOT a way to override a WireKit token. It tells the compiler to generate utility classes for the custom properties declared inside (--color-brand: #ab35ff gives you bg-brand / text-brand), which is exactly what you want for your OWN tokens. A --color-wk-* override placed there loses twice over: Tailwind drops a theme variable that no utility references, so the declaration compiles to nothing at all — and one that survives is emitted inside @layer theme, which puts it on the losing side of the same layered-versus-unlayered rule described below. php artisan wirekit:theme writes a plain :root {} block for this reason.
  • @layer base {} — Tailwind v3-era pattern, still works in v4. Wraps base-layer rules so they cascade correctly. Don't wrap WireKit token overrides in it: wirekit.css ships its defaults as unlayered CSS, and unlayered CSS always wins over layered CSS regardless of specificity — so a --color-wk-* override inside @layer base (or any @layer) silently loses to the WireKit default and has no visible effect. Use it only when you have actual base-layer style declarations to scope, never for token overrides.

Bottom line: every --*-wk-* override goes in a plain :root {} block, with a plain .dark {} beside it for the dark half. Both wrappers above look right and do nothing — and neither of them errors, so the only symptom is a value that stays at its default.

There are two ways to load wirekit.css, and both are valid: the @wirekitStyles directive in your layout, or an @import of the file inside your app.css — wirekit:doctor reports either one as a pass. Pick whichever fits your build pipeline, and use only that one, because both together deliver every token twice. Two valid setup paths for CSS walks through each end to end.

Dark mode works automatically: the .dark class on <html> switches every token to its dark variant. For the page, the class belongs on <html> — What the class carries besides tokens says why — and a panel can switch on its own, as A light island in a dark page shows.

Font Presets

WireKit ships locally bundled, GDPR-compliant Google Fonts that activate via the fonts config and <x-wirekit::fonts /> component. The dedicated Fonts component page is the canonical reference — full setup walk-through, available font list per category, behavior under various states.

To expose WireKit fonts to the global Tailwind v4 token surface so non-WireKit elements pick them up too, add this to your app.css after configuring fonts per the Fonts page:

@theme {
    --font-sans: var(--font-wk-sans);
    --font-serif: var(--font-wk-serif);
    --font-mono: var(--font-wk-mono);
}

Typography Tokens

WireKit exposes eight body-text sizes plus separate heading-weight controls via CSS variables. The full table lives in Design Tokens → Component Sizing; the summary below covers what each token controls and where it appears.

Token Default Typical use
--text-wk-2xs 0.6875rem Micro text — dense secondary labels (e.g. an event time under its title)
--text-wk-xs 0.75rem Caption / micro-label / footer text
--text-wk-sm 0.8125rem Small body / secondary text
--text-wk-md 0.875rem Default body font size
--text-wk-lg 1rem Large body / lede paragraph
--text-wk-xl 1.25rem Sub-heading
--text-wk-2xl 1.5rem Section heading
--text-wk-3xl 1.875rem Page heading
--text-wk-touch-floor 16px The smallest text a form field shows on a touch screen, where iOS zooms into smaller text on focus. Fields and the editor read it, and so does a button given touch-text-floor to share a row with a field, and a field you build yourself given wk-touch-text-floor. Keep it at 16px or above
--text-wk-fit-floor 0.75em The smallest size a fitted text shrinks to when it names no floor of its own
--measure-wk 65ch Readable line-length clamp for long-form <x-wirekit::prose> (default measure)
--measure-wk-wide 78ch Roomier line-length clamp (prose measure="wide")
--ch-wk-sans per family The body face's average character width, published by every bundled family's stylesheet. The two measures above are calc(65 * var(--ch-wk-sans, 1ch)) — a real ch unit measures the element's font, which is wrong for a clamp that has to hold across a page mixing faces
--ch-wk-serif per family The same figure for the serif face
--ch-wk-mono per family The same figure for the mono face

The serif and mono figures are published for YOUR calc(), not read by anything here. WireKit's own measures are sans-based, because the prose surfaces they clamp are. A serif-set article or a code column wants its own line length, and these are what makes it computable — max-width: calc(70 * var(--ch-wk-serif, 1ch)) — instead of a ch unit that would measure whatever face the element happens to inherit.

They were undocumented until 2026-09-09, which is the state that reads as dead weight: 11 font stylesheets declared them and nothing in the package referenced them, so the only way to find out they were an API was to notice they existed. --ch-wk-sans was undocumented too, and it is load-bearing.

Why preset blocks below only override sm/md/lg: the eight sizes form a fluid scale anchored at the body size (--text-wk-md). Each preset adjusts the body-scale band (small / default / lead) so paragraphs feel right for that visual style; the heading sizes (xl/2xl/3xl) and the micro / caption sizes (2xs/xs) keep proportional defaults derived from the body band. If you want an end-to-end custom scale (e.g. for a brand with bigger headings), override every --text-wk-* token in your :root block — see Design Tokens for the worked example.

Accent Color

The accent color controls primary buttons, focus rings, and interactive elements:

:root {
    --color-wk-accent: var(--color-blue-600);
    --color-wk-accent-hover: var(--color-blue-700);
    --color-wk-accent-content: var(--color-blue-700);
    --color-wk-accent-fg: var(--color-white);
    --color-wk-ring: var(--color-blue-600);
}
.dark {
    --color-wk-accent: var(--color-blue-500);
    --color-wk-accent-hover: var(--color-blue-400);
    --color-wk-accent-content: var(--color-blue-400);
    --color-wk-ring: var(--color-blue-500);
}

From a brand color

A preset writes its accent tokens by hand. For a color your application only learns at runtime, the brand color of a shop for one, AccentLadder::for() derives the four of one mode and holds the contrast of each:

use Pushery\WireKit\Theming\AccentLadder;

AccentLadder::for('#e11d48');
// [
//     '--color-wk-accent' => 'oklch(58.58% 0.222 17.58)',
//     '--color-wk-accent-hover' => 'oklch(52.58% 0.2106 17.58)',
//     '--color-wk-accent-content' => 'oklch(52.58% 0.2106 17.58)',
//     '--color-wk-accent-fg' => 'oklch(100% 0 0)',
// ]

AccentLadder::for('#e11d48', dark: true);
// [
//     '--color-wk-accent' => 'oklch(68% 0.2101 17.58)',
//     '--color-wk-accent-hover' => 'oklch(74% 0.1594 17.58)',
//     '--color-wk-accent-content' => 'oklch(71% 0.1839 17.58)',
//     '--color-wk-accent-fg' => 'oklch(20.5% 0 0)',
// ]
  • The label (-fg) reads at 4.5:1 or more on the fill and on the hover, so a primary button stays legible under the pointer. It is the near white or near black of the shipped theme where one of them reads, and pure white or black where neither does.
  • The text color (-content) reads at 4.5:1 or more on the page, card and subtle surfaces of the shipped theme in that mode, and on the tinted surfaces of callout and alert, the accent's own tint among them. It starts at the fill and darkens, or lightens in dark mode, in steps of 1% until it does.
  • The fill (--color-wk-accent) is your color. On a dark page a fill darker than 68% lightness is lifted to 68%, where the presets put theirs. The fill is not measured against the page: a button is named by its label, so a pale brand color makes a pale button with a dark label.
  • The hover lies 6% of lightness away from the page, or toward it where the label would not read on the darker fill or where no room is left.

Each value is an oklch() value inside sRGB, and each ratio is measured on the value as written, with WcagContrast::ratio(). --color-wk-ring keeps what your theme gives it. Write both modes into the page after WireKit's stylesheet and any preset:

{{-- 1. Both modes, from the color your application stores --}}
@php($light = \Pushery\WireKit\Theming\AccentLadder::for($shop->brand_color))
@php($dark = \Pushery\WireKit\Theming\AccentLadder::for($shop->brand_color, dark: true))

{{-- 2. The nonce lets a Content Security Policy accept the block --}}
<style @if($nonce = \Pushery\WireKit\WireKit::cspNonce()) nonce="{{ $nonce }}" @endif>
    :root { @foreach ($light as $token => $value){{ $token }}: {{ $value }}; @endforeach }
    .dark { @foreach ($dark as $token => $value){{ $token }}: {{ $value }}; @endforeach }
</style>

for() throws an InvalidArgumentException for a color it cannot read or one that is not opaque, so a value a user typed is best checked when it is saved.

With a bundled preset, let the command write it: php artisan wirekit:theme soft --accent="#e11d48" derives the accent on the surfaces of the preset it writes, see wirekit:theme.

With a theme of your own, pass its surfaces: the page, card and panel colors, and any tinted surface your links sit on. The tints the accent makes on each are checked as well, since they depend on the accent being derived. The shipped theme's surfaces are then not used:

AccentLadder::for('#e11d48', surfaces: ['oklch(97% 0.01 250)', '#fff']);
// '--color-wk-accent-content' => 'oklch(51.58% 0.2065 17.58)', one step darker than on the shipped theme

Giving info a color of its own

Every info surface takes its color from four tokens: an alert, a callout, a toast, a badge, a banner, a progress bar and a map pin among them. All four alias the accent, so out of the box an informational notice looks like your brand. When your accent already means something else, a green brand for one, which reads as success, point them at a hue of their own:

:root {
    --color-wk-info-tone: var(--color-cyan-700);  /* the tint and the fill */
    --color-wk-info-fg: var(--color-white);       /* text on an info fill */
    --color-wk-info-hover: var(--color-cyan-800); /* an info button under the pointer */
    --color-wk-info-text: var(--color-cyan-800);  /* text and icons on an info tint */
}
.dark {
    --color-wk-info-tone: var(--color-cyan-300);
    --color-wk-info-fg: var(--color-cyan-950);
    --color-wk-info-hover: var(--color-cyan-200);
    --color-wk-info-text: var(--color-cyan-300);
}

Every pair in this block clears 4.5:1 in both modes, the white on the light fill with the least room at 5.28:1. Success keeps its own token, so an info notice and a success message stay apart. An alert, a callout and every toast also carry their intent as data-intent, for a rule of your own: [data-intent="info"].

Dark mode

Switching the theme is one class on <html> — there is no second set of components to maintain. Every token either carries its own value under .dark or resolves through one that does: --color-wk-tooltip-bg is var(--color-wk-text) and --color-wk-tooltip-text is var(--color-wk-bg), so a tooltip inverts with the page without a line of its own. A handful are the same in both themes on purpose — --shadow-wk-none is nothing, and --color-wk-success-fg is a near-black label on a saturated fill that stays readable whichever way the surface goes.

So the rule to follow when you add a token is not "write a .dark line": it is that a token must not resolve to a value that only makes sense in one theme.

You do not have to wire that yourself. Theme controller ships the control, the persistence, and the head script that stops the page flashing white before it turns dark:

<head>
    @wirekitThemeScript   {{-- 1. Applies the stored theme before the first paint --}}
    @wirekitStyles
</head>

<body>
    <x-wirekit::theme-controller />   {{-- 2. The control itself --}}
</body>

With nothing stored, the page follows the reader's operating system — and keeps following it, live, if their machine turns dark at sunset. An explicit choice wins and is remembered.

Anything with its own colors can listen for the change instead of polling:

<div x-data x-on:wirekit:theme-changed.window="applyTheme($event.detail.dark)">…</div>

See theme controller for the variants and the storage key.

What the class carries besides tokens

Alongside the tokens, both blocks declare color-scheme. That is the only signal the browser reads for the widgets it paints rather than we do: the popup a native <select> opens, the document scrollbar, number spinners, the date, time and color pickers, autofill highlighting, the spellcheck underline. Without it those stay light on a dark page — a white dropdown over a near-black surface — however the tokens are set.

It also decides where the class belongs. color-scheme governs the controls inside the element carrying it, but the document canvas and the document scrollbar follow the root element only. Put .dark on <html> — where @wirekitThemeScript puts it — rather than on a wrapper <div>, or you get dark controls inside a light-scrollbarred page.

<body> is not a second-best spot for it either: it is not the root, so the canvas and the document scrollbar stay light there too. The tokens themselves do switch on any element that carries the class. A custom property is computed where it is declared, so the mode classes declare again every token WireKit derives from a mode token, the navigation rail's colors included, and a dark panel anywhere in the page resolves them against itself.

A light island in a dark page

.dark switches any subtree that carries it. light is its mirror: a subtree with that class resolves the light values again, inside a page whose <html> is dark.

{{-- 1. The page is dark. --}}
<html class="dark">
<body>
    {{-- 2. This panel is not. Card, controls and native widgets inside it are light. --}}
    <div class="light">
        <x-wirekit::card>…</x-wirekit::card>
    </div>
</body>
</html>

The class declares exactly the tokens that change with the mode: every value .dark switches, with its light counterpart, and every WireKit token derived from one of them, such as the text of a soft alert, inline code, a tooltip or a rail's colors. That includes color-scheme, so a native select popup, a spinner or a scroll container inside the island is painted light while the rest of the page stays dark.

Nothing else is declared again. A radius, a font, a spacing step or a motion token you override in your own :root reaches into the island unchanged.

A request for more contrast reaches the island as well: data-contrast="more" on <html>, or the reader's operating system asking for it, raises the island's hint text, placeholders and borders just as it raises the page's.

wk-light-only and wk-dark-only follow the island too, and so do your own dark: utilities once your @custom-variant dark line is the one the integration guide shows.

Three limits. The document canvas and the document scrollbar follow the root element, so an island cannot change those — that is a property of the page, not of a panel. A token that changes with the mode is declared on the island, so an override of one in :root alone stops at the island's edge; give the light value to both selectors, the way the dark value belongs on .dark:

:root,
.light { --color-wk-accent: oklch(54.6% 0.245 262.881); }
.dark { --color-wk-accent: oklch(70% 0.2 262.881); }

And a subtree switch only reaches what it declares: tokens your own CSS derives further down from a WireKit token are computed where you declare them, so declare them inside the island as well if the island should see them.

Use it for a side-by-side comparison, a light preview frame, a print-styled panel or a marketing section that stays light on a dark page. For the page as a whole, keep using .dark on <html>.

To decide it yourself, declare it in your own :root, which outranks WireKit's default regardless of load order:

:root { color-scheme: light dark; }   /* hand the native controls straight to the OS */

Theme Presets

WireKit ships eight theme presets. Each one now lives on its own page with the full copy-paste block, prerequisites, and WCAG notes — so this guide stays focused on the shared theming concepts.

  • Theme: Default — the refined neutral baseline (ships out of the box, no CSS needed).
  • Theme: Aurora — modern, color-confident, toned to the brand magenta, with one-line --theme-hue retinting and ready-made color presets.
  • Theme: Brutalist — high-contrast, hard edges, thick borders, zero radius.
  • Theme: Cupertino — Apple-inspired translucency (includes the Liquid Glass extension).
  • Theme: Material — Material Design 3, elevated surfaces, indigo accent, Roboto.
  • Theme: Minimal — radical reduction for data-dense interfaces and admin tools.
  • Theme: Retro Terminal — green-on-black terminal aesthetic, monospace stack.
  • Theme: Soft — warm, rounded aesthetic for an end-user-facing SaaS (violet accent, DM Sans).

Applying a preset — the CLI and the page block are not the same thing

There are two routes, and they are not interchangeable:

  • php artisan wirekit:theme <name> appends the preset's modifier set — the tokens that carry its character — to your app.css. Every token it does not name keeps its stock value.
  • The CSS block on the preset's own page is the complete palette. For most presets it declares materially more than the command writes: background and text roles, control-border pairs, fonts.

Retro Terminal shows the gap most plainly. Its page sets the black background, the green body text and the monospace family; the command sets none of the three, so wirekit:theme retro-terminal leaves you with a green accent and square corners on the stock light surface rather than a terminal.

The command is the fast way in, not a second spelling of the block. After running it, open the preset's page and paste whatever it did not write — that block is the authority for how the preset looks in full.

For the complete CSS-variable reference, see Design Tokens.

Responsive token overrides

The bundled section-spacing tokens (--space-wk-section-sm = 3 rem, --space-wk-section-md = 5 rem, --space-wk-section-lg = 7 rem) are read directly by <x-wirekit::hero size="…"> and <x-wirekit::cta size="…">. The component itself ships a responsive utility: the named size applies at the sm+ breakpoint, and the mobile viewport (< sm) drops one tier automatically. For most projects that is the right shape — no developer-side work needed.

The advanced pattern for projects that want to flip the underlying token values themselves per viewport — not just the tier-binding on the component — lives in your app.css:

:root {
    --space-wk-section-sm: 2rem;
    --space-wk-section-md: 4rem;
    --space-wk-section-lg: 6rem;
}

@media (min-width: 768px) {
    :root {
        --space-wk-section-sm: 3rem;
        --space-wk-section-md: 5rem;
        --space-wk-section-lg: 7rem;
    }
}

@media (min-width: 1280px) {
    :root {
        --space-wk-section-sm: 4rem;
        --space-wk-section-md: 7rem;
        --space-wk-section-lg: 10rem;
    }
}

Why use this over the component's size prop alone? Three cases:

  1. Custom breakpoints. The component's built-in responsive utility flips at Tailwind's sm breakpoint (640 px). If your design system uses different breakpoints (e.g. 768 / 1024 / 1280), the media-query override pattern lets everything that reads the section-spacing tokens track the same custom breakpoints without per-component overrides.
  2. Three-tier-on-three-viewport semantics. The component's utility shifts ONE tier between mobile and sm+. If you want a tighter mobile + comfortable tablet + roomy desktop progression (three values across three breakpoints), the override pattern delivers it.
  3. Cross-primitive coherence. Anything else that reads --space-wk-section-* — including any custom primitives you author — automatically tracks the same scale. The component-level size prop only controls hero/cta.

Combine the two approaches: keep the component-level size prop for per-section intent ("this hero feels lg, this cta feels md"), and use the media-query override to globally tune what sm / md / lg mean across your design system.

Thanks to the :where() spec-0 wrap on WireKit's token blocks, a plain :root {} override wins regardless of load order — so place these declarations in the same :root {} block where you set --color-wk-accent and your other customizations, wherever your build emits it.

The same pattern applies to any other --space-wk-* token (--space-wk-md, --space-wk-xl, etc.) — every token in WireKit's design system is developer-overridable via a :root {} declaration. The section-spacing scale is the one developers most often want to flip per viewport, which is why it gets its own documented recipe.

Toast edge offset

<x-wirekit::toast-region> pins its stack flush to a viewport edge (top-* or bottom-*). If your layout has a fixed header / navigation bar, set --space-wk-toast-offset to its height so toasts never render underneath it:

:root {
    /* 1. Keep top-positioned toasts clear of a 4rem fixed header. */
    --space-wk-toast-offset: 4rem;
}
Token Default Used for
--space-wk-toast-offset 0px Distance the toast stack keeps from its pinned edge — set to your fixed header / nav (or bottom-bar) height

The component folds env(safe-area-inset-top) (for top-* positions) or env(safe-area-inset-bottom) (for bottom-* positions) in on top of this value automatically, so the notch and home indicator are always cleared regardless of what you set. The offset applies only to the active edge; the other three sides keep their default padding.

Classic-scrollbar gutter

A position: fixed surface is laid out against the viewport, and a classic scrollbar on the document is not part of that box: the space a scrollbar takes is subtracted from the containing block of the box that scrolls (CSS 2.1, section 11.1.1). A floating action button that declares 1rem from the right keeps 1rem from the document's scrollbar, and an app whose document scrolls sets nothing.

Two cases need the token, and WireKit fills the first itself:

  • While a modal, drawer, alert dialog, command palette or lightbox is open, the scroll lock hides the document's scrollbar, and the viewport widens by the gutter. The lock publishes that width on --wk-scrollbar-inset for as long as it holds, so the pinned surfaces stay where they were, and gives the root its own value back when it releases.
  • A route whose content lives in a full-height inner container (height: 100dvh; overflow: auto, or <x-wirekit::main>, which scrolls itself) draws its scrollbar inside the viewport, and a pinned surface sits on it. Publish that container's gutter yourself.
:root {
    /* 1. Default. Correct for a document that scrolls, and for overlay
          scrollbars: the macOS default and every touch device. */
    --wk-scrollbar-inset: 0px;
}
// 2. A route that scrolls an inner container publishes that container's
//    gutter where the pinned surfaces read it: on the root when they sit
//    outside the container (a toast region at the end of <body>), on the
//    container when they are its children. `max(0, …)` because a zoomed
//    page can report a fractional negative.
const panel = document.querySelector('main');
const sync = () => document.documentElement.style.setProperty(
    '--wk-scrollbar-inset',
    `${Math.max(0, panel.offsetWidth - panel.clientWidth)}px`,
);

sync();
window.addEventListener('resize', sync);

Re-measure on navigation, not only at boot: the gutter is a property of the page, and a page shorter than its container has none while the next one along is ten screens tall.

Token Default Used for
--wk-scrollbar-inset 0px Width of a classic scrollbar a viewport-pinned surface has to keep clear of, folded into its inline-end offset. Set by the scroll lock while an overlay is open; set it yourself for an inner scroll container

Surfaces that fold it in: <x-wirekit::fab>, <x-wirekit::fab.button>, <x-wirekit::toast-region>, <x-wirekit::scroll-to-top>, <x-wirekit::reading-bookmark>, and the viewport-pinned variant of <x-wirekit::reading-progress>.

<x-wirekit::reading-spine> is the one pinned surface that does not, and deliberately: it already exposes its own lever. Set --reading-spine-edge-offset to calc(1rem + var(--wk-scrollbar-inset)) to have it follow the token as well. The edge-offset prop takes a plain length only, so the expression goes through the token.

Only the edge the scrollbar is on

The block axis is unaffected, and so is the opposite inline edge — there is no gutter under it, and adding the term there would push a surface away from empty space. The absolute and sticky variants of these components resolve against a containing block rather than the viewport and are unaffected entirely.

A strip above the page

<x-wirekit::announcement-banner strip> pins one line to the top of the viewport — the line that says which environment this is, or a maintenance notice — and moves the application down by exactly its own height. It does that through a token, the block-axis sibling of the scrollbar gutter above: while a strip is on the page, --wk-strip-inset holds its height, and every surface that pins itself to the top of the viewport or takes the viewport's height folds it in.

Unlike the gutter, nothing has to measure it. A strip is one line tall by construction, so the stylesheet knows its height before anything paints and sets the inset itself — no script, and no first frame in which the page sits under the strip.

Your own furniture takes part the same way the components do:

/* 1. A bar of your own, pinned to the top: below a strip when there is one, flush without */
.app-toolbar {
    position: sticky;
    top: var(--wk-strip-inset, 0px);
}

/* 2. A full-height layout of your own gives the strip's height back */
.app-canvas {
    height: calc(100dvh - var(--wk-strip-inset, 0px));
}
Token Default Used for
--wk-strip-inset 0px The height the strips on the page take from the top of the viewport — one strip's height while one is present, both together while two are
--wk-strip-height one line of --text-wk-sm plus the xs block padding The height of one strip. Change it and the inset follows

Surfaces that fold it in: <x-wirekit::app-shell> in both modes, the sticky forms of <x-wirekit::navbar>, <x-wirekit::header>, <x-wirekit::shell-bar>, <x-wirekit::brand-bar>, <x-wirekit::toolbar> and <x-wirekit::announcement-banner>, <x-wirekit::page-progress>, the top bar of <x-wirekit::reading-progress>, and the top positions of <x-wirekit::toast-region>.

An overlay — a modal, a drawer, the command palette, a lightbox, a tour — covers the strip along with the page, the way it covers the rest of the application. <x-wirekit::reading-spine> in its container mode sticks at its own offset: set --reading-spine-offset-top to calc(1rem + var(--wk-strip-inset, 0px)) if a strip sits above an article that carries one.

Read the inset, do not set it

--wk-strip-inset is derived from the strips the page contains. Do not set it yourself: a value you write moves every surface above down with no strip there to fill the space. To change how tall a strip is, set --wk-strip-height instead, and the inset follows.

The strip's colors, its hue tone and the tokens that fix its lightness and chroma are on the announcement banner page.

Touch sizes on any pointer

On a touch screen, fields and buttons grow to --size-wk-touch-target (44px), the text in a field takes the 16px floor that keeps a phone from zooming in, and controls that appear on hover stay visible. All of it follows the pointer: a phone gets it, and a desk with a mouse keeps the denser sizes.

Every interactive row grows with them: the items of a sidebar, a dropdown, a context menu and a menubar, the triggers and links of a navigation menu, the entries of a navbar, a bottom navigation, an overflow nav, a reading TOC and a spine rail, the rows of a tree view, and the options of a combobox, a multi-select, a command palette and a scope switcher. Each is at least 44px tall with its content in the middle, and a short one, a menubar name such as File, 44px wide. The label of a checkbox, a radio, a toggle or the switch of a theme controller with a visible text is such a row too: one line of text stands in the middle of the 44px, and a longer text keeps the box of a checkbox or a radio beside its first line. A control without a visible text keeps its own size and answers a finger through a 44px hit area around it. The items of an app rail and of a folded sidebar keep the width of their column, which keeps every icon on one line, and answer a finger over a 44px square that reaches past the item into the column's padding.

The buttons a component draws in its own style grow as well: a tab, a segment of a segmented control, a quick reply and a reaction, Today, the view switch and the agenda rows of an event calendar, the buttons and chips of a filter builder, a copy button with its text, the density switch, the sort heads and Columns of a data table, the filters and rows of a notification center, the interval switch of a pricing table, Back and Next of a tour, a profile that opens a menu, and the steppers of a number input, which then stand as tall as their field. A slider answers a finger over a 44px band with its track where it was, and the swatch of a color picker grows to 44px.

A small icon button inside a component keeps its drawn size and answers a finger over a 44px area around it: the dismiss of an alert, an announcement banner and a toast, the close of the drawer in an app shell, the menu toggle of a sidebar and a navbar, the toggle that folds an app rail, the arrows of a calendar, a carousel and a lightbox, the play button of a carousel, and a copy button without text. A small control that stands beside other controls grows to 44px instead, because an area around it would reach into its neighbors: Confirm and Cancel of an inline edit, the arrows of an event calendar, the arrows that move a row of a reorder list or a table, the control that folds a sidebar, the action beside an entry of an overflow nav, and the remove button in a multi-select list, whose options are 44px rows. The clear button of a combobox takes the touch target as the buttons inside a field do, and the field keeps its text clear of it. Controls that stand closer together than a finger keep their size, because a 44px area would take the taps meant for a neighbor: the dots of a carousel, the days of a calendar, the stars of a rating, the cells of a status matrix, the formatting bar of an editor, and the remove button inside a chip.

A row you build yourself takes the same floor with wk-touch-row, and so does a small button of yours that sits beside other controls: on a wk-button the class also drops the 44px area of an icon-only button, and the button's touch-floor prop sets it for you. Lay its content out centered, so it stays in the middle when the row grows:

{{-- 1. Under a coarse pointer and inside data-wk-touch, the row is at least 44px tall --}}
<a href="/orders/1042" class="wk-touch-row flex items-center">
    {{-- 2. Centered by the row's own flex layout, so the text keeps to the middle --}}
    Order 1042
</a>

A surface that is always worked by finger needs it whatever the pointer says. A till or a packing station with a mouse plugged in reports a fine pointer and got the mouse sizes. Put data-wk-touch on the element that holds such a surface:

{{-- 1. Everything inside takes the touch sizes, on any pointer --}}
<main data-wk-touch>
    {{-- 2. The till's fields, buttons and dialogs --}}
</main>

A dialog, a drawer or a menu opened from inside it takes the touch sizes as well, although it is drawn elsewhere in the page, and since v2.63.0 it keeps them while it is open, through every Livewire update. Before v2.63.0 the first update with the dialog open, a field bound with wire:model.live for one, put its fields and buttons back to the mouse sizes. The attribute changes sizes and what is visible, not how a control behaves: a color picker still asks the device whether to open the system's own picker.

On such a surface the question mark beside a field's label takes the touch target too, and every field label, with help or without, grows to the same height with its text in the middle, so fields side by side keep their controls on one line. That part belongs to the surface alone: on a phone outside it, labels keep their compact rows and the question mark its 24px.

Motion tokens

Reduced motion is honored across the library

A visitor who has asked their system to reduce motion sees WireKit's transitions and animations resolve immediately — every component, not a selected few. It needs no opt-in and there is no token to set.

The rule covers WireKit's own elements and their descendants — the selector ends in [class^="wk-"] *.

Read that as document-wide, not component-scoped. wk-scrollbar is the class WireKit puts on <html> and <body>, so once it is there everything in the document descends from a wk- element: a bare <div> carrying animation: spin 2s appended straight to <body> is clamped to the same 1ms under a reduce preference. Your own animations follow the reader's preference automatically, so an @media (prefers-reduced-motion: reduce) block of your own is redundant rather than wrong.

To let motion through for a reader whose OS flag is set for something else, set data-reduce-motion="no-preference" on <html>.

For a single element, data-wk-motion="essential" releases the clamp on that element and its subtree, under both halves of the rule. It is a claim about the content rather than a way around the preference: WCAG 2.3.3 exempts motion that is essential to the functionality, which is a live trace or a shape whose stillness would read as a hang — not an entrance that looks nicer moving. WireKit's own indicators are outside its reach and stay still: a spinner, a skeleton and an indeterminate progress bar stop rather than slow, and each announces itself through role="status".

There IS an explicit override, and it is not a token. If your application lets a reader choose the setting in-app rather than in their operating system, put data-reduce-motion="reduce" (or "no-preference") on <html> and WireKit follows it — the attribute name is recorded in config/wirekit.php under a11y.motion_attribute so your own rules can read the same one. The setup, including reading it from the signed-in reader, is in the Integration guide.

The motion-rhythm token surface drives every animated component (<x-wirekit::reveal>, <x-wirekit::feature-grid stagger>, <x-wirekit::stats stagger>, every component with an animateIn prop) plus the developer-side wk-animate-* utility classes:

Token Default Used for
--motion-wk-duration-fast 150ms <x-wirekit::reveal duration="fast">
--motion-wk-duration-normal 300ms <x-wirekit::reveal duration="normal"> (default)
--motion-wk-duration-slow 600ms <x-wirekit::reveal duration="slow">
--motion-wk-delay-{none,sm,md,lg,xl} 0ms / 75ms / 150ms / 300ms / 500ms <x-wirekit::reveal delay="…">
--motion-wk-easing-out cubic-bezier(0.16, 1, 0.3, 1) Decelerate-style entrances
--motion-wk-easing-in cubic-bezier(0.7, 0, 0.84, 0) Accelerate-style exits
--motion-wk-easing-spring cubic-bezier(0.5, 1.5, 0.5, 1) Bounce / spring presets
--wk-stagger-step 75ms Per-child increment driving the .wk-stagger > *:nth-child(N) cascade. Emitted by <x-wirekit::feature-grid stagger> and <x-wirekit::stats stagger>. Override per-instance via inline style="--wk-stagger-step: 200ms" OR globally via :root {} for a design-system-wide cascade rhythm. Honors prefers-reduced-motion: reduce — every child's delay collapses to 0.
--shimmer-wk-duration 2s Travel duration of the text-glyph shimmer (<x-wirekit::shimmer> / .wk-text-shimmer). Override per-instance via the component's duration prop or inline style="--shimmer-wk-duration: 3s". Disabled under prefers-reduced-motion: reduce, forced-colors: active, and prefers-reduced-transparency: reduce.
--shimmer-wk-band 3ch Half-width of the bright traveling highlight band in the text-glyph shimmer. Smaller values give a tighter, sharper glint; larger values a broader wash. Override per-instance via inline style="--shimmer-wk-band: 1.5ch".
--shimmer-wk-angle 105deg Tilt of the shimmer's highlight band. Override per-instance via inline style="--shimmer-wk-angle: 90deg" for a vertical sweep.

See Public CSS API → wk-stagger for the full contract on the stagger class.

Your own transitions

@wirekitTransition writes the attributes of an Alpine x-transition onto the element it sits on: a fade with a slight scale on enter and leave, timed by --transition-wk-duration and eased by --transition-wk-easing, the tokens the components' own hover and focus transitions read. A panel of your own then opens and closes at the pace of the components beside it.

{{-- 1. The directive sits on the element that x-show toggles. --}}
<div x-show="open" @wirekitTransition>
    Saved filters
</div>

Changing either token changes this transition as well, and the reduced-motion rule above applies to it like to any other transition on the page.

Theme markers

Some themes go past tokens and dress specific surfaces — adding a glass class to every panel, say. That needs a stable way to find those surfaces, so the components below emit a data-wk-* attribute for exactly that purpose.

The surfaces in the table below are the ones a theme may rely on. They are a contract: each one names a thing you would want to dress, and it will keep naming it.

Other data-wk-* attributes exist in the rendered markup, and they are not part of this contract. Most of them are internal wiring — a hook a component uses to find its own panel, a flag an announcer reads — and they are named after the mechanism rather than after a surface. They may be renamed or removed in any release, and several of them mark something other than what their name suggests (data-wk-tip is the worked example below). A selector aiming at one of those can stop matching without anything in this package changing on purpose.

If you need a surface that is not in the table, ask for it rather than reading one out of the markup — a marker that becomes a contract is cheap, and a theme built on a wiring hook breaks quietly.

Surface Marker Emitted by
Card data-wk-card <x-wirekit::card>
Dropdown panel data-wk-dropdown-panel <x-wirekit::dropdown.panel>
Popover panel data-wk-popover <x-wirekit::popover>
Modal panel data-wk-modal-panel <x-wirekit::modal>
Modal header data-wk-modal-header <x-wirekit::modal.header>
Modal body data-wk-modal-body <x-wirekit::modal.body>
Drawer panel data-wk-drawer-panel <x-wirekit::drawer>
Drawer body data-wk-drawer-body <x-wirekit::drawer.body>

The panel is the surface that paints a modal's or a drawer's background, and the header and body sit inside it. A treatment that shows what lies behind a surface, such as glass, belongs on the panel: on the body it would show the panel's own background.

One marker that is not a surface

data-wk-tip is in the package and looks like it belongs in the table above. It does not, and mapping a theme to it does the wrong thing while appearing to work.

It marks an element that has a tooltip — a truncated event title, a chip, a row — not a tooltip itself. A theme that reads the name as "tooltip" and dresses it frosts the calendar's event pills and never touches a single tooltip. That failure is worse than reaching nothing, because something visibly changes and the map looks correct.

The tooltip itself carries no themeable marker at all, for the reason in the next section: it does not exist until it opens.

Never assume a theme selector matches anything — check it. A selector that matches nothing is the quietest failure available: it throws no error, logs no warning, and produces no visual difference you could compare against, so the theme silently does less than its source file says. You cannot see this by reading the map; assert that each mapping matches at least one element, or the gap sits unnoticed for as long as the map exists.

Runtime-created surfaces are outside a theme's reach

A tooltip is the clear case. Its element does not exist in the server-rendered document — wirekit.js creates it and stamps data-wk-tip at the moment the tip opens. A theme that sweeps the document on load, or on livewire:navigated, looks before the node exists and finds nothing. No selector fixes this, because the problem is timing rather than naming.

If you need to dress a surface like that, observe insertions (a MutationObserver on document.body, filtered to the marker) rather than sweeping once. The same applies to any element a component creates on interaction rather than on render.

Accessibility & Contrast

Every default token pair in WireKit is verified against WCAG 2.2 contrast requirements. The audit covers every bundled theme preset — Default, Minimal, Soft, Material, Brutalist, Retro Terminal, Cupertino, Aurora — in both light and dark mode.

Guaranteed compliance

Every pair in this table holds across every preset × mode combination. Each foreground in a row is guaranteed on each background in that row. Text meets WCAG AA normal-text (4.5:1, WCAG 1.4.3); the focus ring meets the non-text floor (3:1, WCAG 1.4.11).

Role Foreground Background Requirement In your theme
Body text --color-wk-text --color-wk-bg, --color-wk-bg-elevated, --color-wk-bg-subtle 4.5:1 —
Hint text --color-wk-text-muted --color-wk-bg, --color-wk-bg-elevated, --color-wk-bg-subtle, --color-wk-bg-muted 4.5:1 —
Hint and link text on tinted surfaces --color-wk-text-muted, --color-wk-accent-text the tinted surfaces of callout and alert 4.5:1 — not measured
Helper and placeholder text --color-wk-text-subtle, --color-wk-text-placeholder --color-wk-bg, --color-wk-bg-input 4.5:1 —
Primary buttons --color-wk-accent-fg --color-wk-accent, --color-wk-accent-hover 4.5:1 —
Danger buttons --color-wk-danger-fg --color-wk-danger, --color-wk-danger-hover 4.5:1 —
Success buttons --color-wk-success-fg --color-wk-success, --color-wk-success-hover 4.5:1 —
Warning buttons --color-wk-warning-fg --color-wk-warning, --color-wk-warning-hover 4.5:1 —
Semantic message text --color-wk-danger-text, --color-wk-success-text --color-wk-bg, --color-wk-bg-elevated 4.5:1 —
Focus rings --color-wk-ring --color-wk-ring-offset 3:1 —

The tinted surfaces are not tokens of their own: callout and alert mix their state color into --color-wk-bg-elevated, so that row names the components rather than a token pair.

--color-wk-text-subtle and --color-wk-text-placeholder are guaranteed on the surfaces in their row only. A subtle line placed on a tinted callout or alert surface is not covered; use muted there, which is.

The hover fills are covered as well as the resting ones, because a label that clears AA at rest and misses it under the pointer misses it exactly when the button is being used.

Success and warning buttons use dark foreground (neutral-900) on vivid bg colors, not white. White-on-amber-500 only reaches 2.15:1 — far below AA. This is the single most common contrast bug in off-the-shelf design systems, and WireKit refuses to ship it.

Intentional trade-offs

Two categories of token pairs intentionally sit below the strict 4.5:1 / 3:1 thresholds:

  1. Decorative borders (border, border-subtle, border-hover) — these sit at ~1.3-2.5:1 against the page background. WCAG 1.4.11 requires 3:1 only for UI elements that convey state or identify boundaries. Pure decorative dividers are exempt. Every major design system (Shadcn UI, Radix, Tailwind UI, Material, Chakra) uses identical values. Stateful borders that do communicate (focus rings, border-error, border-success, border-unsaved) meet or exceed 3:1. The built-in wirekit:doctor:a11y --theme-contrast audit reflects exactly this split: it reports the resting decorative borders as advisory INFO (decorative, WCAG 1.4.11 exempt) and hard-checks the focus ring plus the stateful borders at 3:1 — so a default install audits clean.

border-strong and border-strong-hover are NOT in that list. They are the resting and hover border of every form control, so they carry the boundary of an interactive element and are contrast-bound: ≥3:1 against the input fill, in both themes. border-strong-hover additionally has to move away from the fill — a hover that lightens toward the field, as border-hover does, drops the control below the threshold exactly while it is being used. A preset that retints border for its controls must set these two as well, or its inputs fall back to the stock neutral.

  1. text-muted on bg-muted narrowly misses AA normal-text — it does not, and has not for some time. Measured against the shipped tokens: oklch(47% 0 0) on oklch(97% 0 0) is 6.26:1, comfortably past AA normal-text. This entry described a deliberate compromise at ~4.1–4.4:1, and the compromise was resolved when the tokens were tightened; only the caveat survived. It is kept here struck through because its advice was actively bad — it steered body copy inside a muted surface away from text-muted, which is the token that belongs there.

Placeholder text

Per WCAG F24 placeholders count as "content that can be dismissed by user action", but WireKit still ships text-placeholder at the same 4.5:1 minimum as body text (neutral-500 light / neutral-400 dark) because the bar should be "always legible," not "legally compliant." We match text-subtle for consistency.

Running your own audit

If you customize tokens (new accent palette, dark mode inversion, etc.) and want to verify AA compliance, start with WireKit's built-in audit, then reach for an external tool for anything it doesn't cover:

  • php artisan wirekit:doctor:a11y --theme-contrast — the built-in audit. Reads your app.css token overrides (filling any gap from the shipped defaults) and reports WCAG 2.1 contrast per canonical pairing × light/dark mode. Per WCAG 1.4.11 the communicating borders (focus ring, border-error, border-success, border-unsaved) are checked at 3:1 while the resting decorative borders are flagged advisory-exempt, so a default install audits clean.
  • WebAIM Contrast Checker — one pair at a time
  • Pa11y — automated page scanning
  • Chrome DevTools → Inspect → Accessibility pane → Contrast ratio

Minimum targets:

  • Normal text: 4.5:1 (AA), 7:1 (AAA)
  • Large text (≥ 18pt or ≥ 14pt bold): 3:1 (AA), 4.5:1 (AAA)
  • UI components, meaningful borders, focus indicators: 3:1

Checking a color pairing in PHP

The audit is built on Pushery\WireKit\Theming\WcagContrast, and you can call it yourself: to label a pairing on a settings page, to check a palette your application generates, or to fail a build on a token you changed.

use Pushery\WireKit\Theming\WcagContrast;

$ratio = WcagContrast::ratio('oklch(47% 0 0)', 'oklch(97% 0 0)'); // 6.26

WcagContrast::conformance($ratio, 'text');       // 'AA': text needs 7:1 for 'AAA'
WcagContrast::conformance($ratio, 'large-text'); // 'AAA'
WcagContrast::conformance($ratio, 'ui');         // 'AA', the highest a UI pairing can reach
  • WcagContrast::ratio($foreground, $background) returns the contrast ratio as a reader sees it, or null when it cannot be measured. It reads hex in all four lengths, rgb() and rgba() in the comma and the space form, oklch(), oklab(), color(srgb …), color-mix(in srgb, …) and transparent, each with its alpha. A translucent foreground is laid over the background first, the way a browser paints it, so half-transparent black on white measures 3.98:1 and not 21:1.
  • null means "not measurable", never "fails". Check for it before you compare, and do not cast it: (float) null reads as a ratio of 0. WcagContrast::unmeasurableReason() takes the same two colors and says which case it was: 'translucent-background', because what shows through depends on whatever lies beneath, or 'unsupported-format', for a named color such as red, for currentColor, or for a color() outside sRGB.
  • WcagContrast::conformance($ratio, $use) grades a ratio as 'AAA', 'AA' or 'fail'. $use is text (4.5:1 for AA, 7:1 for AAA), large-text (3:1 and 4.5:1) or ui, which covers components, graphics and focus indicators at 3:1. WCAG defines no AAA level for non-text contrast, so a ui pairing tops out at 'AA'. Any other $use throws an InvalidArgumentException.
  • WcagContrast::classify($ratio, $threshold) answers the audit's question instead: 'pass', 'warn' or 'fail' against AA, for text or ui, with a warning band of 0.5 below the threshold. A text ratio of 4.2 is 'warn'; 3.9 is 'fail'.
  • WcagContrast::resolveCssVars($value, $vars) fills in var() references before you measure, because a color written with a custom property cannot be parsed until then: oklch(0.55 0.22 var(--theme-hue)) with ['--theme-hue' => '250'] becomes oklch(0.55 0.22 250). A var() whose property is missing resolves to its own fallback.
  • WcagContrast::parseToLinearRgba($color) is the parser underneath, for when you need the channels: linear-sRGB red, green and blue plus alpha, each from 0 to 1, or null for a color it cannot read.
  • WcagContrast::parseToLinearRgb($color) gives the same three channels without the alpha, or null for a color it cannot read or one that is fully transparent. A half-transparent color comes back with its own channels, as if it were opaque; ratio() lays it over the background first.
  • WcagContrast::relativeLuminance($linearRgb) turns those three channels into the relative luminance WCAG defines, from 0 for black to 1 for white. The ratio of two colors is (lighter + 0.05) / (darker + 0.05) of their luminances.

Converting a color to OKLCH

Every --color-wk-* token is an oklch() value. Pushery\WireKit\Theming\Oklch converts a color from any other form and back, with the same matrices the contrast check uses:

use Pushery\WireKit\Theming\Oklch;

Oklch::fromCss('#e11d48');              // [0.5858, 0.222, 17.58], rounded here: lightness, chroma, hue
Oklch::toCss([0.5858, 0.222, 17.58]);   // 'oklch(58.58% 0.222 17.58)'
Oklch::toHex([0.7, 0.4, 150]);          // '#00be58'
  • Oklch::fromCss($color) reads every opaque form WcagContrast reads and returns lightness from 0 to 1, chroma, and the hue in degrees from 0 up to 360. An oklch() value keeps the numbers it was written with, even outside sRGB. A color it cannot read, or one that is not opaque, gives null.
  • Oklch::toCss($lch) and Oklch::toHex($lch) write the color inside sRGB. A color outside it loses chroma at the same lightness and hue: oklch(70% 0.4 150) becomes #00be58, where clipping each channel would give #00d600, which is lighter and of another hue.
  • Oklch::toGamut($lch) returns that reduced color, and Oklch::inGamut($lch) says whether a color fits sRGB as it is.

Checking a color pairing in the browser

The same arithmetic ships as a JavaScript module, because some questions can only be answered where the page is. A theme or mode switch re-resolves every token, and a value computed on the server describes the theme your application declares rather than the one the reader is looking at. When you want to show a reader the contrast their current theme actually produces, measure what the browser resolved.

import { contrast } from './vendor/pushery/wirekit/resources/js/utils/wcag-contrast.js';

const panel = document.querySelector('[data-panel]');
const readout = document.querySelector('[data-contrast-readout]');

// getComputedStyle hands back resolved values, so there is nothing left to substitute.
const styles = getComputedStyle(panel);
const ratio = contrast.ratio(styles.color, styles.backgroundColor);

if (ratio !== null) {
    readout.textContent = `${ratio.toFixed(2)}:1 — ${contrast.conformance(ratio, 'text')}`;
}
  • The surface is the PHP class's, method for method: ratio(), unmeasurableReason(), classify(), conformance(), relativeLuminance(), parseToLinearRgb() and parseToLinearRgba(), with the same arguments, the same return values and the same null for a pairing it cannot measure. Everything said about the PHP methods above holds here, including that null means "not measurable" and never "fails" — and in JavaScript a null compared with < reads as 0, which is the same trap in a different spelling. An unknown use in conformance() throws an Error.
  • Import it on its own or take it from the bundle. The module has no dependencies and ships inside the package, so importing the file directly costs you the arithmetic and nothing else. If you already import the ESM bundle, it is there as a named export: import { contrast } from '.../dist/wirekit.esm.js'.
  • resolveCssVars() is deliberately absent. The PHP class needs it because it reads a stylesheet as text; a browser resolves var() chains before it hands a value to getComputedStyle. Pass the resolved value.
  • The two implementations are held against each other, over one shared corpus of colors, pairings and ratios, with identical answers required down to the last bits — including where both must refuse. Two implementations of one formula drift silently otherwise, each green against its own expectations.

Syntax-highlighter contract

WireKit ships the design tokens but does not ship a syntax highlighter — highlight.js, Prism, Shiki, or any other choice remains your responsibility. WireKit's own <x-wirekit::code-block> component renders the surface (<pre> background, padding, rounded corners) using --color-wk-bg-elevated and --color-wk-text for the body text, but token-level coloration is delegated to whichever highlighter you wire up.

If you load a syntax-highlighter theme on top of <x-wirekit::code-block>, the theme's per-token colors must meet WCAG 2.2 AA (≥4.5:1) against the active --color-wk-bg-elevated, in BOTH light and dark mode, across every theme preset you ship to your users. This is non-negotiable — code blocks are body text, and 4.5:1 is the threshold.

Two foot-guns to watch for:

  1. Light-mode WCAG overrides that bleed into dark mode. A common pattern is to load a base highlight.js theme (github.min.css) and then override individual .hljs-* token colors in your own CSS to hit AA against your light surface. If those overrides are unscoped (e.g. plain .docs-prose pre .hljs-keyword { color: ... }), they'll keep winning the cascade against your dark-mode bundle (github-dark-dimmed.min.css) too — painting dark light-mode colors onto a dark background. Always scope WCAG overrides with html:not(.dark) (or your equivalent dark-mode root selector) so they drop out of the cascade in dark mode.

  2. Per-theme --color-wk-bg-elevated variations. Different theme presets ship different elevated-surface tones (Retro Terminal #1a1a1a, Cupertino #2C2C2E, Default oklch(20.5% 0 0) ≈ #353535, etc.). A token palette that passes 4.5:1 against one preset's bg-elevated may fail against another. Audit token contrast on every preset × every mode combination, not just one pair.

A practical audit recipe (Playwright + WCAG 2.1 contrast computation):

// pseudo-code: iterate themes × modes × representative pages,
// walk every `.docs-prose pre code.hljs span[class^="hljs-"]`,
// compute getComputedStyle().color vs effective non-transparent
// background, fail the build on any sample below 4.5:1.
for (const theme of THEMES) {
  for (const mode of ['light', 'dark']) {
    await page.goto(`...`);
    await page.evaluate(([t, m]) => {
      localStorage.setItem('wirekit-theme', t);
      localStorage.setItem('wirekit-dark-mode', m === 'dark' ? '1' : '0');
    }, [theme, mode]);
    await page.reload();
    // ... walk DOM, compute contrast, exit non-zero on failure
  }
}

If you ship syntax-highlighted code blocks in your own developer app, wire an equivalent contrast audit into your CI — walk every theme × mode × representative page, compute foreground-vs-background contrast for every .hljs-* token-class, and fail the build below the WCAG 4.5:1 threshold for normal text.

Which spacing family do I use?

There are three spacing families, they share the tier names xs…2xl, and a tier name does not mean the same distance in all three. --space-wk-md is 1rem, --gap-wk-md is 0.75rem, --padding-wk-y-md is 0.5rem. Past md the scales stop running parallel, and --space-wk-xl (2.5rem) is larger than --gap-wk-2xl (2rem).

That matters the moment you replace a raw value with a token, because both halves of the move are individually correct and the result can still be wrong: gap-4 is 1rem, and 1rem is both --space-wk-md and --gap-wk-lg. One reads as "a medium distance", the other as "a large" — the same number, two opposite statements about intent. Later, when one scale moves, half of your views move with it and the other half do not, and the name does not say which.

Pick by what the distance IS, not by which value matches:

The distance you are naming Family Example
between siblings in a flex row or a grid --gap-wk-* gap-[var(--gap-wk-md)]
inside a control, from its edge to its content --padding-wk-x-* / --padding-wk-y-* px-[var(--padding-wk-x-md)]
between blocks, sections, and page rhythm --space-wk-* mt-[var(--space-wk-lg)]

A container pads evenly. The x tier of each step is wider than its y tier, which suits a button or a chip. A box that holds content and controls (a card, an alert, a panel, a dialog) takes the x tier on both axes instead, so a button at its end sits as far from the bottom edge as from the side: px-[var(--padding-wk-x-lg)] py-[var(--padding-wk-x-lg)]. WireKit's own containers do this; give your own boxes the same pair.

The one deliberate exception: a margin that CANCELS a padding reads the padding's own token. A child that bleeds out of its parent's inset is describing that inset, not page rhythm, so -mx-[var(--padding-wk-x-md)] beside a px-[var(--padding-wk-x-md)] parent is correct and a --space-wk-* value there would silently stop matching the day the padding scale moves.

Choose the family first, then the tier. Done the other way round — find the value, then look up a name that carries it — you will land in whichever family happens to have that number, which is how two views end up with gap-[var(--gap-wk-sm)] and gap-[var(--space-wk-xs)] side by side under names that sound like the same system.

WireKit's own catalog predates this rule and does not follow it everywhere — roughly 146 of its utility usages read a family that is not the one above. They are held at that number rather than grown, and they are not being swapped mechanically: --padding-wk-x-sm is 0.625rem and no gap tier carries that value, so a "rename" would quietly change how things look.

Design Token Reference

The complete CSS-variable surface — every --color-wk-*, --radius-wk-*, --shadow-wk-*, motion / typography / sizing token, plus the reading-* component family and chart-theming palette — lives on its own dedicated page so this guide can stay focused on setup and theming concepts.

→ Design Tokens — full reference


The token surface is intentionally orthogonal to the theme presets above: change a preset to swap visual style, change a token to fine-tune a specific dimension across every component that reads from it.

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store