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.
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.
/* 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.
/* 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.
/* 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.
<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.
/* 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.
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.
// zyncat.theme.tsimport { 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 surfacesexport 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.
// app/layout.tsximport '@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.
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.
/* app.css - the stylesheet Tailwind compiles; init writes this line */@import '@zyncat/ui/tailwind.css';@import 'tailwindcss';<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>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.
{/* 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;}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.
{/* 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.
<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.