Theming & Overrides

Theme every component with typed design tokens and plain CSS variables. Four override levels, no ThemeProvider.

Four ways in

Take the lowest level that does the job. Never fork the source.

LevelMechanismReachesUse it for
0Your own CSSEvery instanceA rule the tokens have no name for.
1zyncat.theme.css or defineThemeThe whole systemA rebrand, dark mode, retiming motion.
2--component-* propertiesOne componentRetuning an expressive component.
3className, style, htmlPropsOne instanceThis one, in this one place.

Level 0 — Your CSS wins

Every shipped rule sits inside a cascade layer, and unlayered CSS beats a layer at any specificity. Write a normal rule and it lands. No !important, no parent selector.

Shipped
One unlayered rule
css
/* your-app.css - loaded after @zyncat/ui/styles.css */
:where(.zc-btn) {
border-radius: 0;
text-transform: uppercase;
}
 
/* specificity (0,0,0), and it still wins: every shipped rule
sits in @layer zyncat.components, below any unlayered rule */

Class names are stable BEM under one zc- namespace: .zc-btn, .zc-btn--primary, .zc-fld__input, .zc-dialog__body. These docs are a level 0 consumer; the buttons on the right are one such rule.

Level 1 — Tokens

Eight values are decisions and everything else derives from them. init wrote them into zyncat.theme.css beside your app entry. A retheme is editing a value there.

DecisionWhat follows it
--accentHover, active, wash, border, the focus ring, info.
--successIts subtle, text and wash.
--warningIts subtle, text and wash.
--dangerThe danger button, its ring, subtle, text and wash.
--neutralThe gray ramp. The accent by default, so chrome shares its temperature.
--radiusEvery corner step. 0 squares everything.
--font-bodyEvery type role.
--font-codeThe code role.
Default tokens
DraftPublished
Two tokens repointed
DraftPublished
css
/* zyncat.theme.css - written by init, beside your app entry */
:root {
--accent: oklch(0.58 0.19 292); /* hover, active, wash, ring and info follow */
--radius: var(--radius-full); /* every corner step follows */
}

Components and the motion engine read the tokens live, so animation retimes with the CSS.

css
/* escapes the reduced-motion collapse */
.hero {
--duration-fast: 90ms;
}
 
/* covered by it */
:root {
--duration-fast: 90ms;
}

Dark mode

Dark ships in the package. One attribute turns the page or any subtree, and data-polarity="light" inside it makes a light island. ThemeSwitcher writes it for you, persists the choice and follows the OS; useTheme is the same state as a hook.

tsx
<html lang="en" data-polarity="dark">
 
{/* a light island inside it */}
<section data-polarity="light">
<Button variant="primary">Light in here</Button>
</section>

Your decisions cascade in, so your accent is the dark theme’s accent too. To change what dark does, extend it in the same file. Anything you leave out keeps the shipped value.

css
/* zyncat.theme.css */
[data-polarity='dark'] {
--accent: oklch(0.72 0.14 292); /* a lighter accent for dark surfaces */
--shadow-strength: 2.5; /* the lighting model: shadows, highlights, glow */
--sheen-strength: 0.3;
--glow-strength: 0.6;
}

The tokens you use

Your own pages read the same tokens the components do, so what you build sits on the same surfaces, ink and corners, and follows a retheme.

FamilyTokensPick it for
Surfaces--bg-app, --bg-surface, --bg-surface-raised, --bg-subtle, --bg-muted, --bg-inset, --bg-overlayThe page, a card, a raised panel, a quiet fill, a recessed well, the scrim behind an overlay.
Ink--text-strong, --text-body, --text-secondary, --text-muted, --text-subtle, --text-disabled, --text-accent, --text-on-accent, --text-inverseHeadings, body copy, supporting copy, hints, placeholders, a link, text on an accent fill.
Borders--border-subtle, --border-default, --border-strongA divider, a control edge, an emphasised edge.
Status--accent, --success, --warning, --danger, --info, each with -subtle and -textA status fill, its quiet background, its readable text. Status hues mark genuine status only.
Type--type-display-lg … --type-micro, --type-code, --font-body, --font-codeOne font shorthand per role, size and leading matched. Eleven of them.
Space--space-px, --space-1 … --space-10Padding and gaps on the 4px grid: 4, 8, 12, 16, 24, 32, 48, 64, 96, 128.
Radius--radius-sm, --radius-md, --radius-lg, --radius-xl, --radius-2xl, --radius-fullControls take md, cards lg, sheets xl, pills full. Every step is a ratio of --radius.
Elevation--shadow-xs … --shadow-xl, --focus-ring, --shadow-strength, --sheen-strength, --glow-strengthLift, the one focus treatment every control shares, and the three numbers a theme scales the lighting with.
Motion--duration-fast … --duration-slowest, --ease-standard, --ease-entrance, --ease-exit, --ease-spring, --ease-glide, --transition-control, --transition-colors, --transition-opacityYour own transitions on the system’s bands. Reduced motion collapses them for you.

get_tokens in the MCP server prints all of them with live values. Once @zyncat/ui/theme is imported anywhere, every token is typed on any component’s style prop.

The typed theme

The same tokens, with a type. Use it when a theme is data: several named themes, or values computed at build time.

DraftPublished4820
Each knob writes one typed key. The accent's hover, wash and focus ring, and every corner step, derive from it. The preview scopes the theme to this panel; the theme switch is the shipped dark on the panel's root.
tsx
// zyncat.theme.ts
import { defineTheme } from '@zyncat/ui/theme';
 
export const light = defineTheme({
color: { accent: 'oklch(0.58 0.19 292)' },
shape: { radius: '0.75rem' },
type: { font: { body: "'Inter', system-ui, sans-serif" } },
motion: { duration: { base: '180ms' } },
components: { odometer: { ink: 'var(--warning)' }, supportRail: { width: '22rem' } },
});
 
// a delta over light - only what differs on dark surfaces
export const dark = defineTheme({
color: { accent: 'oklch(0.72 0.14 292)' },
custom: { '--shadow-strength': 2.5 },
});

Render ZyncatTheme once, first in <body>. It renders a <style> element and an inline script that paints the stored choice onto <html> before first paint: no provider, no flash, nothing to configure. Durations you set here keep their reduced-motion collapse.

tsx
// app/layout.tsx
import '@zyncat/ui/styles.css';
 
import { ZyncatTheme } from '@zyncat/ui/theme';
import { light, dark, ocean } from '../zyncat.theme';
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ZyncatTheme themes={{ default: { light, dark }, ocean }} />
{children}
</body>
</html>
);
}

default lands on :root. Every other key becomes a [data-theme='<key>'] block, and the boot script keeps the choice on <html>, so a switch is one call.

tsx
import { useTheme } from '@zyncat/ui/theme';
import { ThemeSwitcher } from '@zyncat/ui/theme-switcher';
 
{/* the shipped control - every declared palette, light, dark and system */}
<ThemeSwitcher labels={{ default: 'Acme', ocean: 'Ocean' }} />
 
{/* the same state as a hook */}
const { theme, polarity, resolvedPolarity, setTheme, setPolarity } = useTheme();
setTheme('ocean');
setPolarity('system'); // follows the OS, live
 
{/* or one subtree - a palette needs both attributes on the element */}
<section data-theme="ocean" data-polarity="light">
<Button variant="primary">Ocean accent in here only</Button>
</section>
  • The shape of a theme

    Four groups - color, type, shape, motion - then components for the per-component knobs, and custom for any other token by its CSS name.

  • Generated from the CSS

    The types are built from the token stylesheets. Every key completes, hover shows the default, a typo is a compile error.

One writer per decision: on this route, drop those lines from zyncat.theme.css.

With Tailwind

On Tailwind v4 the vocabulary is a set of utilities, with IntelliSense. init writes one import above tailwindcss; the base stylesheet stays on its JS import.

css
/* app.css - the stylesheet Tailwind compiles; init writes this line */
@import '@zyncat/ui/tailwind.css';
@import 'tailwindcss';
tsx
<article className="bg-surface border border-subtle rounded-lg shadow-sm p-4 max-w-prose">
<h3 className="text-heading text-strong">Weekly digest</h3>
<p className="text-caption text-muted">Sent every Monday at 9:00.</p>
<button className="bg-accent-fill text-on-accent rounded-md px-3 py-2 duration-fast ease-standard hover:bg-accent-hover">
Enable
</button>
</article>
FamilyUtilitiesReads
Surfacesbg-app, bg-surface, bg-surface-raised, bg-subtle, bg-muted, bg-inset, bg-overlay--bg-*
Inktext-strong, text-default, text-secondary, text-muted, text-subtle, text-disabled, text-accent, text-on-accent, text-inverse; text-success, text-warning, text-danger, text-info--text-*, --<hue>-text
Hairlinesborder-subtle, border-default, border-strong--border-*
Huesbg-accent, bg-accent-fill, hover:bg-accent-hover, bg-accent-wash, border-accent-border, ring-accent, bg-danger/10 … on every colour utility--accent*, --success*, --warning*, --danger*, --info*, --neutral-wash*
Typetext-micro, text-caption, text-body, text-body-lg, text-label, text-heading, text-title, text-title-lg, text-display, text-display-lg, text-code + font-code; font-body, leading-<role>, tracking-caps, tracking-display--type-*, --font-*, --leading-*, --tracking-*
Corners, elevationrounded-sm … rounded-2xl, rounded-full, shadow-xs … shadow-xl, shadow-glow-<hue>, outline-ring-<hue>--radius-*, --shadow-*, --focus-ring, --ring-*, --ring-color-*, --glow-*
Motionduration-fast … duration-slowest, ease-standard, ease-entrance, ease-exit, ease-spring, ease-glide--duration-*, --ease-*
Measuremax-w-prose, max-w-floating--measure-*

Each utility reads the token itself, so themes and dark: reach it, and dark: follows data-polarity. Tailwind’s own rounded-md and shadow-md read the zyncat token of the same name. Spacing stays Tailwind’s scale.

Level 2 — One component

Expressive and compound components publish --<component>-<name> properties. Set them inline, or on any ancestor to reach every instance beneath it. Primitives publish none; retheme those at level 1.

Default4820
Three properties set4820
tsx
{/* one instance */}
<Odometer value={total} style={{ '--odometer-size': 'var(--size-display-lg)', '--odometer-ink': 'var(--danger)' }} />
 
{/* every instance in the app */}
defineTheme({ components: { odometer: { size: 'var(--size-display-lg)', ink: 'var(--danger)' } } });
 
/* every instance under one element */
.metrics-panel {
--odometer-size: var(--size-display-lg);
--odometer-gap: 0.12em;
}
ComponentPropertiesFor example
Odometer5--odometer-size, --odometer-ink, --odometer-gap
TypingLines7--typing-lines-caret-ink, --typing-lines-blink
Lens4--lens-surface, --lens-fringe-warm, --lens-fringe-cool
MorphingText10--morphing-text-size, --morphing-text-smear
WeightField14--weight-field-peak-weight, --weight-field-hover-padding
FlowField15--flow-field-ramp-0 … -11, --flow-field-accent
Confetti11--confetti-paper-1 … -5, --confetti-weights
SupportRail12--support-rail-width, --support-rail-accent, --support-rail-row-pad-block

The knobs are typed twice: as the components group of a theme, and on the component’s own style prop. Private state is a --_<component>-* property, off the type, so setting one is a compile error.

tsx
{/* this component's knobs are typed on its style prop */}
<Odometer value={total} style={{ '--odometer-size': '3rem', '--odometer-ink': 'var(--danger)' }} />
 
{/* another component's knob: compile error */}
<Odometer value={total} style={{ '--lens-ink': 'red' }} />
 
{/* private state: compile error */}
<Odometer value={total} style={{ '--_odometer-cell': '1em' }} />

Canvas simulations pick a change up at their next measure: FlowField and WeightField on resize, Confetti on the next fire().

Level 3 — One instance

Props move one instance. What a component accepts depends on how many surfaces it renders.

One element. className and style land on it and merge with the shipped classes. htmlProps carries the native attributes that would collide with a prop.

tsx
<Button className="checkout-cta" style={{ minWidth: '12rem' }}>
Place order
</Button>
 
/* .zc-btn is still there; your class rides alongside it */
.checkout-cta {
width: 100%;
}

Replicas

FacebookFeed, InstagramFeed, TikTok and YouTube reproduce a real platform surface, so their metrics are pinned constants. A retheme cannot move them and there is nothing to set at level 2. For a card that follows your theme, build one from primitives.

Frequently asked questions

Design tokens are the named CSS custom properties every component reads instead of a hard-coded value: --accent, --space-4, --radius-md, --duration-base. Zyncat UI ships a closed vocabulary of them grouped by job - color, type, space, radius, elevation, motion, glass, icon, layer, avatar - so repointing one token moves every component that uses it. They are plain CSS on :root, not a build-time pipeline.

Set them on :root in your own stylesheet, or use the typed API: defineTheme({ color: { accent: 'oklch(0.58 0.19 292)' } }) and render <ZyncatTheme theme={{ base }} /> once at the app root. The typed route autocompletes every token name and turns a typo into a compile error, and the style prop on every component accepts the design tokens too.

Load your stylesheet after @zyncat/ui/styles.css and write a normal rule - .zc-btn { border-radius: 0 } just lands. Every shipped rule sits inside @layer zyncat.components, and unlayered CSS beats every layer at any specificity, so there is no !important, no specificity ladder and no parent selector to lean on. Every class is BEM off a short base behind the zc- namespace, so nothing in your own sheet can collide with one - .zc-btn, .zc-btn--primary, .zc-btn__label, .zc-fld__input - and the names are stable to target.

Dark ships in the package. Toggling is one attribute - document.documentElement.dataset.theme = 'dark' - and the page, the body included, turns with no re-render and no reload; put data-theme on any element instead of <html> to turn one subtree, and data-theme="light" inside it makes a light island. Extend it under the same attribute: a [data-theme='dark'] block in zyncat.theme.css, or a dark key in <ZyncatTheme theme={{ base, dark }} />.

No. ZyncatTheme is not a context provider: it renders a plain <style> element, so it server-renders with no flash, no client hook and no PostCSS or bundler plugin, and it adds about a kilobyte. Nothing subscribes to it, which is why switching themes is a DOM attribute rather than a React re-render.

Expressive and compound components publish scoped --<component>-<name> custom properties as their public contract: <Odometer value={total} style={{ '--odometer-size': '3rem', '--odometer-ink': 'var(--danger)' }} />. Each component's style prop is typed to its own knobs, so another component's property is a compile error. Set the same properties on any ancestor to reach every instance underneath.

Yes, on Tailwind v4. Import @zyncat/ui/tailwind.css above tailwindcss in the stylesheet Tailwind compiles - init writes the line - and the token vocabulary becomes utilities that Tailwind IntelliSense completes: bg-surface, text-muted, border-subtle, text-caption, rounded-md, shadow-md, duration-fast, ease-standard. Each utility reads the design token itself, so a themed subtree and the dark theme reach it, dark: follows data-theme, and Tailwind's own rounded-md and shadow-md read the same tokens the components do.

Set in Geist & Newsreader — animated by the house engineZyncat UI · Rev 0.11 · MIT · Built by Tabsir Ahammed · Source on GitHub