Appearance
Styling
The token system
Kairo has no tailwind.config.js — Tailwind v4 is configured entirely inline in src/index.css via an @theme block. That block defines CSS custom properties like --color-surface, --color-primary, --color-on-secondary, --radius-lg, and font families. Tailwind then auto-generates matching utility classes (bg-surface, text-on-secondary, rounded-lg, ...).
css
/* src/index.css */
@theme {
--color-surface: #141313;
--color-secondary: #4648d4;
--color-on-secondary: #ffffff;
--radius-lg: 0.5rem;
/* ...more tokens */
}Rule: never hardcode a color in a component (no bg-[#141313], no inline style prop with a literal hex color). Always use a token class. If the color you need doesn't have a token yet, add one to the @theme block (or extend the light-theme override below) rather than reaching for a literal.
DESIGN.md (repo root) is the authority on what tokens exist and what they're for — check it before inventing a new token, and update it if you add one. npm run design:lint checks the file for consistency, and npm run design:export:tailwind can regenerate a Tailwind-compatible export from it.
Dark / light theming
Dark is the default and is just the token values written directly in :root/@theme. Light mode is a second set of the same custom properties, scoped under [data-theme="light"]:
css
/* src/index.css */
[data-theme="light"] {
--color-surface: #fdfcff;
--color-on-surface: #1b1b1f;
/* ... */
}src/lib/theme.ts is what flips the attribute:
ts
export function applyTheme(pref: ThemePref): void {
document.documentElement.dataset.theme = resolveTheme(pref)
}resolveTheme turns a stored preference ('light' | 'dark' | 'system') into an actual 'light' | 'dark', and initThemeSync() applies it on load and keeps it synced to both the settings store and the OS prefers-color-scheme media query. Because component classes reference tokens (bg-surface, not a literal hex), the exact same JSX renders correctly in both themes automatically — there is nothing to change per-component when someone switches themes.
Worked example: restyling Button
src/components/ui/Button.tsx is a good example of the whole pattern in one file:
tsx
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-secondary text-on-secondary font-bold shadow-lg shadow-secondary/10 hover:opacity-90 active:scale-95',
secondary: 'border border-outline-variant/30 text-on-surface font-medium hover:bg-surface-container-high',
ghost: 'border border-outline-variant/60 text-on-surface hover:bg-surface-container-high',
danger: 'text-error hover:bg-error-container/50',
}
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-2 py-1 text-body-sm',
md: 'px-6 py-3 text-body-md',
lg: 'px-8 py-4 text-body-lg',
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', className, children, ...props }, ref) => (
<button
ref={ref}
className={cn(
'rounded-lg font-medium transition-all duration-200 disabled:opacity-30 ...',
variantStyles[variant],
sizeStyles[size],
className
)}
{...props}
>
{children}
</button>
)
)(a) Changing the button's own look
To change what "primary" looks like everywhere, edit variantStyles.primary — e.g. swap bg-secondary for a new token, or add rounded-full. Because every <Button variant="primary"> in the app shares this one object, the change applies globally in one edit. To add a whole new variant (e.g. outline), add a key to ButtonVariant, add a matching entry to variantStyles, done — no other file needs to change.
(b) cn() — the clsx + tailwind-merge pattern
cn (from src/lib/utils.ts) combines clsx (conditionally join class strings) with tailwind-merge (dedupe conflicting Tailwind classes, keeping the last one). This is why className is accepted last in cn(base, variantStyles[variant], sizeStyles[size], className) — a caller passing className="px-10" will correctly override the variant's px-6, instead of both classes existing and CSS specificity/order picking a winner unpredictably.
(c) Overriding from the parent vs. the child
Because Button forwards className through cn(...), a parent can override spacing/sizing per-instance without touching Button.tsx at all:
tsx
// override just this instance's horizontal padding
<Button variant="primary" className="px-10">Save</Button>But a Button's position relative to its siblings (margin between buttons, alignment in a row) is not the button's job — that belongs to the parent container's layout classes, not something you pass into className on the button itself:
tsx
// good: parent owns the gap/alignment
<div className="flex items-center gap-3">
<Button variant="ghost">Cancel</Button>
<Button variant="primary">Save</Button>
</div>
// avoid: don't reach for margin utilities on the button to fake a gap
<Button variant="primary" className="ml-3">Save</Button>The rule of thumb: the component owns its own visual identity (color, padding, radius, font-weight) via its variant/size maps; the parent owns layout (gap, alignment, width in a flex/grid) via its own container classes. If you find yourself passing margin or flex classes into a component's className prop from many call sites, that's a signal the parent layout should own it instead.
Where to start editing
- New color/spacing/radius token ->
src/index.css@themeblock (and the[data-theme="light"]override if it needs a different light-mode value) + updateDESIGN.md. - A shared primitive's look (Button, Modal, Switch, ...) ->
src/components/ui/*.tsx. - Layout spacing between elements -> the parent container's classes, not the child's.