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 byvar()calls inside Blade templates, so a:rootoverride 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: #ab35ffgives youbg-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:themewrites 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.cssships 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:: 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:: 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 ofcalloutandalert, 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::. --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 Invalid 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
@wirekit 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-hueretinting 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 yourapp.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:: 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:
- Custom breakpoints. The component's built-in responsive utility flips at Tailwind's
smbreakpoint (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. - 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. - Cross-primitive coherence. Anything else that reads
--space-wk-section-*— including any custom primitives you author — automatically tracks the same scale. The component-levelsizeprop 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:: 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-insetfor 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::, which scrolls itself) draws its scrollbar inside the viewport, and a pinned surface sits on it. Publish that container's gutter yourself.main>
: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::, <x-wirekit::, <x-wirekit::, <x-wirekit::, <x-wirekit::, and the viewport-pinned variant of <x-wirekit::.
<x-wirekit:: 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.
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:: 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:: in both modes, the sticky forms of <x-wirekit::, <x-wirekit::, <x-wirekit::, <x-wirekit::, <x-wirekit:: and <x-wirekit::, <x-wirekit::, the top bar of <x-wirekit::, and the top positions of <x-wirekit::.
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:: 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.
--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
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/ under a11y. 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::, <x-wirekit::, <x-wirekit::, 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
@wirekit 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 Mutation 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:
- 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-inwirekit:doctor:a11y --theme-contrastaudit reflects exactly this split: it reports the resting decorative borders as advisoryINFO (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-strongandborder-strong-hoverare 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-hoveradditionally has to move away from the fill — a hover that lightens toward the field, asborder-hoverdoes, drops the control below the threshold exactly while it is being used. A preset that retintsborderfor its controls must set these two as well, or its inputs fall back to the stock neutral.
— it does not, and has not for some time. Measured against the shipped tokens:text-mutedonbg-mutednarrowly misses AA normal-textoklch(47% 0 0)onoklch(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 fromtext-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 yourapp.csstoken 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 (focusring,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\, 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::returns the contrast ratio as a reader sees it, orratio($foreground, $background) nullwhen it cannot be measured. It reads hex in all four lengths,rgb()andrgba()in the comma and the space form,oklch(),oklab(),color(srgb …),color-mix(in srgb, …)andtransparent, 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.nullmeans "not measurable", never "fails". Check for it before you compare, and do not cast it:(float) nullreads as a ratio of 0.WcagContrast::takes the same two colors and says which case it was:unmeasurable Reason() 'translucent-background', because what shows through depends on whatever lies beneath, or'unsupported-format', for a named color such asred, forcurrentColor, or for acolor()outside sRGB.WcagContrast::grades a ratio asconformance($ratio, $use) 'AAA','AA'or'fail'.$useistext(4.5:1 for AA, 7:1 for AAA),large-text(3:1 and 4.5:1) orui, which covers components, graphics and focus indicators at 3:1. WCAG defines no AAA level for non-text contrast, so auipairing tops out at'AA'. Any other$usethrows anInvalid.Argument Exception WcagContrast::answers the audit's question instead:classify($ratio, $threshold) 'pass','warn'or'fail'against AA, fortextorui, with a warning band of 0.5 below the threshold. A text ratio of 4.2 is'warn'; 3.9 is'fail'.WcagContrast::fills inresolve Css Vars($value, $vars) 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']becomesoklch(0.55 0.22 250). Avar()whose property is missing resolves to its own fallback.WcagContrast::is the parser underneath, for when you need the channels: linear-sRGB red, green and blue plus alpha, each from 0 to 1, orparse To Linear Rgba($color) nullfor a color it cannot read.WcagContrast::gives the same three channels without the alpha, orparse To Linear Rgb($color) nullfor 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::turns those three channels into the relative luminance WCAG defines, from 0 for black to 1 for white. The ratio of two colors isrelative Luminance($linear Rgb) (lighter + 0.05) / (darker + 0.05)of their luminances.
Converting a color to OKLCH
Every --color-wk-* token is an oklch() value. Pushery\ 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::reads every opaque formfromCss($color) WcagContrastreads and returns lightness from 0 to 1, chroma, and the hue in degrees from 0 up to 360. Anoklch()value keeps the numbers it was written with, even outside sRGB. A color it cannot read, or one that is not opaque, givesnull.Oklch::andtoCss($lch) Oklch::write the color inside sRGB. A color outside it loses chroma at the same lightness and hue:toHex($lch) oklch(70% 0.4 150)becomes#00be58, where clipping each channel would give#00d600, which is lighter and of another hue.Oklch::returns that reduced color, andtoGamut($lch) Oklch::says whether a color fits sRGB as it is.inGamut($lch)
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(),unmeasurable,Reason() classify(),conformance(),relative,Luminance() parseandTo Linear Rgb() parse, with the same arguments, the same return values and the sameTo Linear Rgba() nullfor a pairing it cannot measure. Everything said about the PHP methods above holds here, including thatnullmeans "not measurable" and never "fails" — and in JavaScript anullcompared with<reads as 0, which is the same trap in a different spelling. An unknownuseinconformance()throws anError. - 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' resolveis deliberately absent. The PHP class needs it because it reads a stylesheet as text; a browser resolvesCss Vars() var()chains before it hands a value toget. Pass the resolved value.Computed Style - 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:: 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::, 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:
-
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.) too — painting dark light-mode colors onto a dark background. Always scope WCAG overrides withmin. css html:not(.dark)(or your equivalent dark-mode root selector) so they drop out of the cascade in dark mode. -
Per-theme
--color-wk-bg-elevatedvariations. Different theme presets ship different elevated-surface tones (Retro Terminal#1a1a1a, Cupertino#2C2C2E, Defaultoklch(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.