Theming and tokens
Colors, typography, radii, shadows, motion and how to change the brand color.
The whole design lives in one file — @akhatmirzo/tokens/theme.css. It holds CSS variables
(--ak-*) and an @theme block that turns them into Tailwind utilities. Components never use
raw colors like indigo-600 — only semantic tokens such as bg-primary and
text-text-muted. That is why dark mode and the brand color change in one place.
@import 'tailwindcss';
@import '@akhatmirzo/tokens/theme.css';Tokens come in two layers:
- Base scales —
--ak-indigo-600,--ak-slate-200and so on. They exist to build the semantic tokens. Don't use them directly in components or app code. - Semantic tokens —
--ak-surface,--ak-text-muted,--ak-primaryand so on. Each one points to a different base value in light and dark mode, and shows up in Tailwind asbg-surface,text-text-muted,bg-primary.
Color palette
The accent is indigo and the neutral is slate. Status colors are emerald (success), amber
(warning), rose (danger) and sky (info). The values below are read straight from theme.css.
'use client';
import { useSyncExternalStore } from 'react';
const SCALES = {
indigo: ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'],
slate: ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'],
emerald: ['50', '100', '300', '400', '500', '600', '700', '950'],
amber: ['50', '100', '300', '400', '500', '600', '700', '950'],
rose: ['50', '100', '300', '400', '500', '600', '700', '950'],
sky: ['50', '100', '300', '400', '500', '600', '700', '950'],
};
const subscribe = () => () => {};
// CSS o'zgaruvchilarning haqiqiy qiymatlari (serverda — bo'sh)
const readValues = () => {
const style = getComputedStyle(document.documentElement);
return Object.entries(SCALES)
.flatMap(([name, steps]) =>
steps.map((s) => style.getPropertyValue(`--ak-${name}-${s}`).trim()),
)
.join(',');
};
export default function ThemingPalette() {
const values = useSyncExternalStore(subscribe, readValues, () => '').split(',');
let index = 0;
return (
<div className="flex w-full flex-col gap-5">
{Object.entries(SCALES).map(([name, steps]) => (
<div key={name} className="flex flex-col gap-2">
<span className="text-sm font-semibold text-text">{name}</span>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6 lg:grid-cols-11">
{steps.map((step) => {
const value = values[index++];
return (
<div key={step} className="flex flex-col gap-1">
<div
className="h-10 rounded-md border border-border"
style={{ background: `var(--ak-${name}-${step})` }}
/>
<span className="text-xs font-medium text-text">{step}</span>
<span className="font-mono text-xs text-text-muted uppercase">{value}</span>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
Semantic tokens
Pick from this list when you write code. Switch the site theme — the swatches and values follow dark mode.
'use client';
import { useSyncExternalStore } from 'react';
const TOKENS = [
['background', 'Sahifa foni'],
['surface', 'Karta, input, modal'],
['surface-muted', 'Hover, ikkilamchi fon'],
['surface-raised', 'Popover, toast'],
['border', 'Oddiy chegara'],
['border-strong', 'Hover chegara'],
['text', 'Asosiy matn'],
['text-subtle', 'Ghost tugma, ikonka'],
['text-muted', 'Yordamchi matn'],
['text-faint', 'Placeholder'],
['primary', 'Asosiy tugma'],
['primary-soft', 'Soft tugma, badge'],
['primary-soft-foreground', 'Soft ustidagi matn'],
['link', 'Havola'],
['danger', 'Xavfli tugma'],
['ring', 'Fokus halqasi'],
] as const;
// Mavzu almashganda (<html data-theme>) qiymatlar qayta o'qiladi
const subscribe = (onChange: () => void) => {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
};
const readValues = () => {
const style = getComputedStyle(document.documentElement);
return TOKENS.map(([token]) => style.getPropertyValue(`--ak-${token}`).trim()).join('|');
};
export default function ThemingSemantic() {
const values = useSyncExternalStore(subscribe, readValues, () => '').split('|');
return (
<div className="grid w-full gap-3 sm:grid-cols-2">
{TOKENS.map(([token, note], i) => (
<div key={token} className="flex items-center gap-3">
<div
className="size-10 shrink-0 rounded-lg border border-border"
style={{ background: `var(--ak-${token})` }}
/>
<div className="flex min-w-0 flex-col text-xs">
<span className="truncate font-mono font-medium text-text">{token}</span>
<span className="truncate text-text-muted">{note}</span>
<span className="truncate font-mono text-text-faint">{values[i] || '—'}</span>
</div>
</div>
))}
</div>
);
}
| Token | Tailwind | Light | Dark | Used for |
|---|---|---|---|---|
background | bg-background | slate-50 | slate-900 | Page background |
surface | bg-surface | white | slate-800 | Cards, inputs, modals |
surface-muted | bg-surface-muted | slate-100 | slate-700 | Hover, secondary fill |
surface-raised | bg-surface-raised | white | slate-750 | Popovers, toasts, menus |
border | border-border | slate-200 | slate-700 | Default border |
border-strong | border-border-strong | slate-300 | slate-600 | Hover border |
text | text-text | slate-900 | slate-100 | Body text |
text-subtle | text-text-subtle | slate-700 | slate-300 | Ghost buttons, icons |
text-muted | text-text-muted | slate-500 | slate-400 | Secondary text |
text-faint | text-text-faint | slate-400 | slate-500 | Placeholders |
primary | bg-primary | indigo-600 | indigo-600 | Primary button |
primary-hover | bg-primary-hover | indigo-700 | indigo-500 | Primary button hover |
primary-foreground | text-primary-foreground | white | white | Text on primary |
primary-soft | bg-primary-soft | indigo-50 | indigo-950 | Soft buttons, badges |
primary-soft-hover | bg-primary-soft-hover | indigo-100 | indigo-900 | Soft button hover |
primary-soft-foreground | text-primary-soft-foreground | indigo-700 | indigo-300 | Text on soft |
link | text-link | indigo-600 | indigo-300 | Links |
ring | outline-ring | indigo-500 / 40% | indigo-400 / 50% | Focus ring |
overlay | bg-overlay | slate-900 / 40% | slate-950 / 60% | Backdrop behind modals |
danger | bg-danger | rose-600 | rose-500 | Destructive button |
danger-hover | bg-danger-hover | rose-700 | rose-600 | Destructive button hover |
danger-ring | outline-danger-ring | rose-500 / 25% | rose-500 / 35% | Focus in error state |
Primary stays the same in dark mode
The primary button is indigo-600 in both modes. Indigo-500 with white text falls short of WCAG AA contrast, so in dark mode only the hover state gets lighter.
Status colors
Each status has two sets: soft for badge and message backgrounds, solid for dots and fully filled badges.
softsuccesswarningdangerinfosolidsuccesswarningdangerinfo'use client';
const STATUSES = ['success', 'warning', 'danger', 'info'] as const;
const SOFT = {
success: 'bg-success-soft text-success-soft-foreground',
warning: 'bg-warning-soft text-warning-soft-foreground',
danger: 'bg-danger-soft text-danger-soft-foreground',
info: 'bg-info-soft text-info-soft-foreground',
};
const SOLID = {
success: 'bg-success text-success-foreground',
warning: 'bg-warning text-warning-foreground',
danger: 'bg-danger text-danger-foreground',
info: 'bg-info text-info-foreground',
};
export default function ThemingStatus() {
return (
<div className="flex flex-col gap-3">
{[SOFT, SOLID].map((styles, row) => (
<div key={row} className="flex flex-wrap items-center gap-2">
<code className="w-12 font-mono text-xs text-text-muted">
{row === 0 ? 'soft' : 'solid'}
</code>
{STATUSES.map((status) => (
<span
key={status}
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
))}
</div>
))}
</div>
);
}
| Status | Soft | Solid |
|---|---|---|
success | bg-success-soft text-success-soft-foreground | bg-success text-success-foreground |
warning | bg-warning-soft text-warning-soft-foreground | bg-warning text-warning-foreground |
danger | bg-danger-soft text-danger-soft-foreground | bg-danger text-danger-foreground |
info | bg-info-soft text-info-soft-foreground | bg-info text-info-foreground |
Typography
The main typeface is Manrope (Latin and Cyrillic); code uses JetBrains Mono. theme.css
only names the fonts (font-sans, font-mono) — it doesn't load them. Add them yourself:
pnpm add @fontsource-variable/manrope @fontsource-variable/jetbrains-mono@import '@fontsource-variable/manrope';
@import '@fontsource-variable/jetbrains-mono';Without the fonts, the system font (ui-sans-serif, system-ui) takes over. The size scale ships
with line heights and letter spacing:
text-4xl36 / 40O'zbekiston — Ўзбекистон
text-3xl30 / 36O'zbekiston — Ўзбекистон
text-2xl24 / 32O'zbekiston — Ўзбекистон
text-xl20 / 28O'zbekiston — Ўзбекистон
text-lg18 / 28O'zbekiston — Ўзбекистон
text-base16 / 24O'zbekiston — Ўзбекистон
text-sm14 / 20O'zbekiston — Ўзбекистон
text-xs12 / 16O'zbekiston — Ўзбекистон
font-mono: 1 250 000 so'm · +998 90
'use client';
const SCALE = [
{ className: 'text-4xl font-bold', name: 'text-4xl', spec: '36 / 40' },
{ className: 'text-3xl font-bold', name: 'text-3xl', spec: '30 / 36' },
{ className: 'text-2xl font-semibold', name: 'text-2xl', spec: '24 / 32' },
{ className: 'text-xl font-semibold', name: 'text-xl', spec: '20 / 28' },
{ className: 'text-lg font-medium', name: 'text-lg', spec: '18 / 28' },
{ className: 'text-base', name: 'text-base', spec: '16 / 24' },
{ className: 'text-sm', name: 'text-sm', spec: '14 / 20' },
{ className: 'text-xs font-medium', name: 'text-xs', spec: '12 / 16' },
];
export default function ThemingTypography() {
return (
<div className="flex w-full flex-col divide-y divide-border">
{SCALE.map((item) => (
<div key={item.name} className="flex items-baseline gap-4 py-2">
<div className="flex w-24 shrink-0 flex-col">
<code className="font-mono text-xs text-text">{item.name}</code>
<span className="text-xs text-text-muted">{item.spec}</span>
</div>
<p className={`${item.className} truncate text-text`}>O'zbekiston — Ўзбекистон</p>
</div>
))}
<p className="pt-3 font-mono text-sm text-text-muted">font-mono: 1 250 000 so'm · +998 90</p>
</div>
);
}
Font weight isn't tied to size — each component sets its own font-medium or font-semibold.
As a rule of thumb: buttons and labels 500, headings 600, large headings 700. Add tabular-nums
to tables and prices so the digits line up.
Radii and shadows
Buttons and inputs use rounded-lg (12px), cards and popovers rounded-xl (16px), modals and
drawers rounded-2xl (20px). Shadows are tinted slate-900 instead of black, which keeps them soft.
In dark mode, shadow-md and up add a 1px ring, because a shadow alone barely shows on a dark
background.
rounded-sm6pxrounded-md8pxrounded-lg12pxrounded-xl16pxrounded-2xl20pxrounded-full9999pxshadow-xsInput, tugmashadow-smKartashadow-mdDropdownshadow-lgPopover, toastshadow-xlModal, drawer'use client';
const RADII = [
['rounded-sm', '6px'],
['rounded-md', '8px'],
['rounded-lg', '12px'],
['rounded-xl', '16px'],
['rounded-2xl', '20px'],
['rounded-full', '9999px'],
];
const SHADOWS = [
['shadow-xs', 'Input, tugma'],
['shadow-sm', 'Karta'],
['shadow-md', 'Dropdown'],
['shadow-lg', 'Popover, toast'],
['shadow-xl', 'Modal, drawer'],
];
export default function ThemingRadiusShadow() {
return (
<div className="flex w-full flex-col gap-8">
<div className="grid grid-cols-3 gap-4 sm:grid-cols-6">
{RADII.map(([className, value]) => (
<div key={className} className="flex flex-col items-center gap-2 text-xs">
<div className={`size-14 border-2 border-primary bg-primary-soft ${className}`} />
<code className="font-mono text-text">{className}</code>
<span className="text-text-muted">{value}</span>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-5">
{SHADOWS.map(([className, note]) => (
<div
key={className}
className={`flex h-20 flex-col items-center justify-center rounded-xl bg-surface-raised text-xs ${className}`}
>
<code className="font-mono text-text">{className}</code>
<span className="text-text-muted">{note}</span>
</div>
))}
</div>
</div>
);
}
Motion
Motion carries information, not decoration: it shows where an element came from and where it
went. Entrances are slower and use ease-out; exits are faster and use ease-in.
ease-outKirishlar (asosiy)ease-inChiqishlarease-in-outJoyida o'zgarishease-springToast, switch'use client';
import { Button } from '@akhatmirzo/ui';
import { useState } from 'react';
const EASINGS = [
['ease-out', 'Kirishlar (asosiy)'],
['ease-in', 'Chiqishlar'],
['ease-in-out', "Joyida o'zgarish"],
['ease-spring', 'Toast, switch'],
];
export default function ThemingMotion() {
const [on, setOn] = useState(false);
return (
<div className="flex w-full max-w-lg flex-col gap-4">
<Button size="sm" variant="primary" className="self-start" onClick={() => setOn((v) => !v)}>
{on ? 'Qaytarish' : 'Ishga tushirish'}
</Button>
{EASINGS.map(([easing, note]) => (
<div key={easing} className="flex items-center gap-3">
<div className="flex w-28 shrink-0 flex-col text-xs">
<code className="font-mono text-text">{easing}</code>
<span className="text-text-muted">{note}</span>
</div>
<div className="relative h-6 flex-1 rounded-full bg-surface-muted">
{/* Qatlam kengligi = yo'lak − sharcha, shuning uchun translate-x-full oxirigacha olib boradi */}
<div
className={`absolute inset-y-0 left-0 w-[calc(100%-1.5rem)] transition-transform duration-slow ${easing} ${on ? 'translate-x-full' : ''}`}
>
<div className="size-6 rounded-full bg-primary" />
</div>
</div>
</div>
))}
</div>
);
}
| Duration | Value | Used for |
|---|---|---|
duration-fast | 100ms | Hover, press, closing a dropdown |
duration-base | 150ms | Opening a dropdown, tooltips |
duration-moderate | 200ms | Opening a modal, accordions, tabs |
duration-slow | 300ms | Drawers, toast entrance |
Easings: ease-out (the default), ease-in, ease-in-out and ease-spring (use sparingly —
toasts, switches). There are ready keyframes for overlays too: animate-dialog-in/out,
animate-popover-in/out, animate-tooltip-in/out, animate-slide-in-right (plus left, top,
bottom), animate-toast-in/out, animate-accordion-down/up, as well as animate-spinner and
animate-shimmer.
prefers-reduced-motion
When the user turns on "reduce motion" in their OS, the popover slide is switched off. Use the
motion-reduce: variant in your own animations.
Layers (z-index)
These aren't mapped to Tailwind utilities — use the CSS variable: z-(--ak-z-modal).
| Variable | Value | Who uses it |
|---|---|---|
--ak-z-dropdown | 1000 | Dropdown, Select, Combobox, Popover |
--ak-z-overlay | 1100 | Dialog, Drawer and Command Palette backdrop |
--ak-z-modal | 1110 | Dialog, Drawer and Command Palette panel |
--ak-z-toast | 1200 | Toast |
--ak-z-tooltip | 1300 | Tooltip |
Changing the brand color
The brand color is the --ak-primary* group. Redefine it after theme.css — the components
don't need to change. In the example below the brand color changes inside a single block.
'use client';
import { Badge, Button, Field, Input, Switch } from '@akhatmirzo/ui';
import { useState, type CSSProperties } from 'react';
const BRANDS = ['indigo', 'emerald', 'sky', 'rose'] as const;
// Brend rangini faqat shu blok ichida almashtiramiz. color-mix — light va dark'da birdek ishlaydi.
const brandVars = (c: string) =>
({
'--ak-primary': `var(--ak-${c}-600)`,
'--ak-primary-hover': `var(--ak-${c}-700)`,
'--ak-primary-soft': `color-mix(in srgb, var(--ak-${c}-500) 14%, var(--ak-surface))`,
'--ak-primary-soft-hover': `color-mix(in srgb, var(--ak-${c}-500) 22%, var(--ak-surface))`,
'--ak-primary-soft-foreground': `color-mix(in srgb, var(--ak-${c}-600) 65%, var(--ak-text))`,
'--ak-ring': `color-mix(in srgb, var(--ak-${c}-500) 45%, transparent)`,
}) as CSSProperties;
export default function ThemingBrand() {
const [brand, setBrand] = useState<(typeof BRANDS)[number]>('emerald');
return (
<div className="flex w-full max-w-md flex-col gap-4">
<div className="flex flex-wrap gap-2">
{BRANDS.map((b) => (
<Button
key={b}
size="sm"
variant={b === brand ? 'outline' : 'ghost'}
onClick={() => setBrand(b)}
>
{b}
</Button>
))}
</div>
<div
style={brandVars(brand)}
className="flex flex-col gap-4 rounded-xl border border-border bg-surface p-5"
>
<Field label="Telefon">
<Input placeholder="+998 90 123 45 67" />
</Field>
<Switch defaultChecked label="Yangiliklarga obuna" />
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary">Saqlash</Button>
<Button>Bekor qilish</Button>
<Badge color="primary">Yangi</Badge>
</div>
</div>
</div>
);
}
To change it across the app, write the light and dark values separately. Any color from the base scales works, and so do your own hex values:
@import 'tailwindcss';
@import '@akhatmirzo/tokens/theme.css';
:root,
[data-theme='light'] {
--ak-primary: var(--ak-emerald-600);
--ak-primary-hover: var(--ak-emerald-700);
--ak-primary-soft: var(--ak-emerald-50);
--ak-primary-soft-hover: var(--ak-emerald-100);
--ak-primary-soft-foreground: var(--ak-emerald-700);
--ak-ring: rgb(16 185 129 / 0.4);
--ak-link: var(--ak-emerald-700);
}
[data-theme='dark'] {
--ak-primary: var(--ak-emerald-600);
--ak-primary-hover: var(--ak-emerald-500);
--ak-primary-soft: var(--ak-emerald-950);
--ak-primary-soft-hover: var(--ak-emerald-950);
--ak-primary-soft-foreground: var(--ak-emerald-300);
--ak-ring: rgb(52 211 153 / 0.5);
--ak-link: var(--ak-emerald-300);
}Write the dark block as well
A :root value on <html> also wins in dark mode: both selectors have the same specificity and
your file comes later. But tokens that differ between modes (soft, ring, link) and nested
data-theme="dark" blocks need a complete [data-theme='dark'] block.
Text on primary is white (--ak-primary-foreground). If you pick a light color such as amber,
check the contrast and change --ak-primary-foreground too if needed.
Tailwind utilities
Besides colors, radii and shadows, theme.css adds a few ready utilities:
| Utility | What it does |
|---|---|
focus-ring | A 2px ring on keyboard focus only (:focus-visible) |
focus-ring-within | For input containers: border and ring when something inside has focus; danger on error |
duration-fast … slow | Duration tokens (pair with transition-*) |
ease-out, ease-spring ... | Easing tokens |
dark: | Works inside data-theme="dark" (not class="dark") |
<button className="focus-ring rounded-lg bg-primary-soft px-4 py-2 text-sm font-medium text-primary-soft-foreground transition-colors duration-base hover:bg-primary-soft-hover">
Filter
</button>Outside Tailwind (your own CSS, inline styles, a chart library) colors, shadows, durations, easings and layers are available as CSS variables:
.chart-tooltip {
background: var(--ak-surface-raised);
color: var(--ak-text);
box-shadow: var(--ak-shadow-lg);
transition: opacity var(--ak-duration-base) var(--ak-ease-out);
z-index: var(--ak-z-tooltip);
}