@loke/design-system
Version:
A design system with individually importable components
355 lines (279 loc) • 13.1 kB
Markdown
---
name: theming
description: >
Customize design system appearance via CSS custom properties and Tailwind
utilities. Brand color scale --brand-50 through --brand-950 (oklch).
Semantic tokens: --primary, --secondary, --accent, --destructive, --muted,
--background, --foreground, --card, --popover, --border, --input, --ring.
Sidebar tokens, chart tokens. Dark mode via .dark class (not
prefers-color-scheme). Border radius derived from --radius base. cn() utility
(clsx + tailwind-merge). CVA for variant definitions. Override via className
props using semantic token classes (bg-primary, text-muted-foreground). Activate
when customizing theme, colors, dark mode, or component appearance.
type: core
library: '@loke/design-system'
library_version: '2.0.0-rc.6'
requires:
- getting-started
sources:
- 'LOKE/merchant-frontends:packages/design-system/src/styles/index.css'
- 'LOKE/merchant-frontends:packages/design-system/src/styles/theme.css'
- 'LOKE/merchant-frontends:packages/design-system/src/lib/cn'
- 'LOKE/merchant-frontends:apps/office/src/styles.css'
---
## Dependency
This skill builds on getting-started. Read it first for CSS configuration.
## Setup
The design system uses CSS custom properties (tokens) defined on `:root` for light mode and overridden on `.dark` for dark mode. Tailwind's `@theme` block maps these tokens to Tailwind utility classes, so you write `bg-primary` or `text-muted-foreground` instead of raw color values.
```css
/* Light mode (default) */
:root {
--primary: oklch(51.4% 0.2276 276.98);
--primary-foreground: oklch(99.17% 0.0028 325.6);
--background: var(--color-brand-50);
--foreground: var(--color-brand-950);
/* ... */
}
/* Dark mode override */
.dark {
--primary: oklch(60% 0.25 276.98);
--primary-foreground: oklch(15% 0.01 325.6);
--background: oklch(15% 0.02 274.82);
--foreground: var(--color-brand-50);
/* ... */
}
```
In components, use the Tailwind utility classes that map to these tokens:
```tsx
<div className="bg-background text-foreground">
<h1 className="text-primary">Heading</h1>
<p className="text-muted-foreground">Subdued text</p>
</div>
```
## Core Patterns
### Semantic Token System
All semantic tokens defined in the design system CSS. Each has a light (`:root`) and dark (`.dark`) value. The `@theme` block maps `--token` to `--color-token`, enabling Tailwind classes like `bg-primary`, `text-card-foreground`, etc.
| Token | Tailwind Class (bg/text) | Light Value | Dark Value |
|---|---|---|---|
| `--background` | `bg-background` | `var(--color-brand-50)` | `oklch(15% 0.02 274.82)` |
| `--foreground` | `text-foreground` | `var(--color-brand-950)` | `var(--color-brand-50)` |
| `--primary` | `bg-primary`, `text-primary` | `var(--color-brand-500)` | `oklch(60% 0.25 276.98)` |
| `--primary-foreground` | `text-primary-foreground` | `oklch(99.17% 0.0028 325.6)` | `oklch(15% 0.01 325.6)` |
| `--secondary` | `bg-secondary` | `oklch(72.29% 0.1438 163.11)` | `oklch(55% 0.16 163.11)` |
| `--secondary-foreground` | `text-secondary-foreground` | `oklch(99.78% 0.0068 115.7)` | `oklch(15% 0.02 115.7)` |
| `--accent` | `bg-accent` | `oklch(96.71% 0.0029 264.54)` | `oklch(30% 0.05 264.54)` |
| `--accent-foreground` | `text-accent-foreground` | `oklch(21.03% 0.0318 264.65)` | `oklch(95% 0.04 264.65)` |
| `--destructive` | `bg-destructive` | `oklch(58.52% 0.18 24.61)` | `oklch(60% 0.22 24.61)` |
| `--destructive-foreground` | `text-destructive-foreground` | `oklch(98.48% 0.0213 193.18)` | `oklch(15% 0.02 193.18)` |
| `--muted` | `bg-muted` | `oklch(96.71% 0.0029 264.54)` | `oklch(25% 0.01 264.54)` |
| `--muted-foreground` | `text-muted-foreground` | `oklch(55.13% 0.0233 264.36)` | `oklch(75% 0.03 264.36)` |
| `--card` | `bg-card` | `oklch(99.16% 0.0029 247.85)` | `oklch(20% 0.01 247.85)` |
| `--card-foreground` | `text-card-foreground` | `oklch(37.39% 0.0822 285.49)` | `oklch(85% 0.04 283.75)` |
| `--popover` | `bg-popover` | `oklch(99.16% 0.0029 247.85)` | `oklch(18% 0.015 247.85)` |
| `--popover-foreground` | `text-popover-foreground` | `oklch(37.1% 0.0722 285.35)` | `oklch(92% 0.07 285.35)` |
| `--border` | `border-border` | `oklch(90.58% 0.013831 272.4947)` | `oklch(30% 0.02 272.49)` |
| `--input` | `border-input` | `oklch(87.53% 0.0142 268.67)` | `oklch(28% 0.02 268.67)` |
| `--ring` | `ring-ring` | `oklch(74.83% 0.1308 254.23)` | `oklch(65% 0.18 254.23)` |
The base layer applies defaults globally:
```css
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
```
### Brand Color Scale
The brand scale defines `--brand-50` through `--brand-950` in oklch. These feed into `--primary`, `--background`, and `--foreground` via `var()` references.
| Token | Value |
|---|---|
| `--brand-50` | `oklch(96% 0.04 276.98)` |
| `--brand-100` | `oklch(92% 0.08 276.98)` |
| `--brand-200` | `oklch(83% 0.13 276.98)` |
| `--brand-300` | `oklch(74% 0.18 276.98)` |
| `--brand-400` | `oklch(63% 0.21 276.98)` |
| `--brand-500` | `oklch(51.4% 0.2276 276.98)` |
| `--brand-600` | `oklch(42% 0.2 276.98)` |
| `--brand-700` | `oklch(33% 0.17 276.98)` |
| `--brand-800` | `oklch(24% 0.13 276.98)` |
| `--brand-900` | `oklch(15% 0.09 276.98)` |
| `--brand-950` | `oklch(5% 0.05 276.98)` |
All brand tokens share the same hue (`276.98`). The `@theme` block maps these to `--color-brand-*`, enabling Tailwind classes like `bg-brand-500` or `text-brand-200`.
To customize the brand color, override the full scale in your consumer CSS `:root` block:
```css
:root {
--brand-50: oklch(96% 0.03 150);
--brand-100: oklch(92% 0.06 150);
/* ... override all 11 steps ... */
--brand-950: oklch(5% 0.04 150);
}
```
### Dark Mode
Dark mode is activated by adding the `.dark` class to the `<html>` or `<body>` element. The design system uses a custom variant:
```css
@custom-variant dark (&:is(.dark *));
```
This means `dark:` utilities in Tailwind only activate when an ancestor has the `.dark` class -- not via `prefers-color-scheme`. Semantic tokens auto-switch between light and dark values, so most components need no `dark:` prefix at all.
```tsx
// Toggle dark mode
document.documentElement.classList.toggle("dark");
```
### cn() Utility
`cn()` combines `clsx` (conditional class joining) with `twMerge` (Tailwind class deduplication). Import from `@loke/design-system/cn`.
```ts
import { cn } from "@loke/design-system/cn";
// Conditional classes
cn("px-4 py-2", isActive && "bg-primary text-primary-foreground");
// Override base classes -- twMerge resolves conflicts
cn("bg-muted text-sm", className); // className="bg-primary" wins over bg-muted
```
Source implementation:
```ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
### CVA for Component Variants
Components use `class-variance-authority` (CVA) to define variant-driven class sets. Here is the Button component as a reference:
```tsx
import { cn } from "@loke/design-system/cn";
import { cva, type VariantProps } from "class-variance-authority";
export const buttonVariants = cva(
cn(
"inline-flex items-center justify-center shrink-0 whitespace-nowrap",
"rounded-lg border border-transparent text-sm font-medium",
"disabled:pointer-events-none disabled:opacity-50",
),
{
defaultVariants: {
size: "default",
variant: "default",
},
variants: {
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "size-8",
},
variant: {
default:
"bg-primary text-primary-foreground hover:bg-brand-900 dark:hover:bg-brand-100",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/60",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
},
},
);
```
Variants reference semantic tokens (`bg-primary`, `text-destructive-foreground`) so they auto-switch in dark mode. The `className` prop is passed through `cn()` to allow per-instance overrides:
```tsx
<Button variant="outline" className="bg-muted">
Custom background
</Button>
```
### Overriding Component Appearance
All components accept a `className` prop merged via `cn()`. Use semantic token classes for overrides so dark mode continues to work:
```tsx
<Card className="bg-muted" />
<Button className="bg-brand-500 hover:bg-brand-600" />
<Badge className="bg-accent text-accent-foreground" />
```
### Chart Tokens
Five chart tokens for data visualization (e.g., recharts):
| Token | Light Value | Dark Value |
|---|---|---|
| `--chart-1` | `var(--color-brand-300)` | `oklch(0.70 0.13 162)` |
| `--chart-2` | `oklch(85.59% 0.0601 285.82)` | `oklch(0.68 0.12 198)` |
| `--chart-3` | `oklch(72.29% 0.1438 163.11)` | `oklch(0.68 0.14 53)` |
| `--chart-4` | `oklch(75.5% 0.145 230.45)` | `oklch(0.66 0.18 285)` |
| `--chart-5` | `oklch(68.75% 0.154 38.2)` | `oklch(0.65 0.19 16)` |
Use in Tailwind: `bg-chart-1`, `text-chart-3`, `stroke-chart-2`, etc.
### Sidebar Tokens
Dedicated tokens for sidebar theming. The sidebar uses a dark background even in light mode by default:
| Token | Light Value | Dark Value |
|---|---|---|
| `--sidebar` | `oklch(21.03% 0.0316 264.78)` | `oklch(20% 0.01 247.85)` |
| `--sidebar-foreground` | `oklch(95.05% 0.0041 286.32)` | `oklch(97% 0.01 286.32)` |
| `--sidebar-accent` | `oklch(28.01% 0.0249 258.35)` | `oklch(28.01% 0.0249 258.35)` |
| `--sidebar-accent-foreground` | `oklch(72.47% 0.1495 160.94)` | `oklch(55% 0.16 163.11)` |
| `--sidebar-border` | `oklch(28.01% 0.0249 258.35)` | `oklch(28.01% 0.0249 258.35)` |
| `--sidebar-ring` | `oklch(74.83% 0.1308 254.23)` | `oklch(65% 0.18 254.23)` |
Tailwind classes: `bg-sidebar`, `text-sidebar-foreground`, `border-sidebar-border`, etc.
### Border Radius
Border radius derives from a single `--radius` base (default `0.5rem`):
```css
--radius-sm: calc(var(--radius) - 4px); /* 0.125rem at default */
--radius-md: calc(var(--radius) - 2px); /* 0.375rem at default */
--radius-lg: var(--radius); /* 0.5rem at default */
--radius-xl: calc(var(--radius) + 4px); /* 0.75rem at default */
```
Override `--radius` in your consumer CSS to scale all radii uniformly:
```css
:root {
--radius: 0.75rem; /* larger, more rounded */
}
```
## Common Mistakes
### CRITICAL: Using raw color values instead of semantic tokens
```tsx
// Wrong -- breaks in dark mode, ignores theme
<div className="bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100">
// Correct -- auto-switches with theme
<div className="bg-muted text-muted-foreground">
```
Semantic tokens handle light/dark switching automatically. Raw Tailwind color classes (`gray-*`, `blue-*`) bypass the theme system entirely.
### HIGH: Overriding brand colors in Tailwind config instead of CSS
```js
// Wrong -- do not add colors to tailwind.config.js or @theme
module.exports = {
theme: {
extend: {
colors: {
brand: { 500: "#5B21B6" },
},
},
},
};
// Correct -- override CSS custom properties in your consumer CSS
// (e.g., apps/office/src/styles.css)
:root {
--brand-50: oklch(96% 0.03 270);
--brand-500: oklch(51% 0.23 270);
/* ... all 11 steps ... */
}
```
The `@theme` block in `index.css` already maps `--brand-*` variables to `--color-brand-*`. Override the CSS variables, not the Tailwind config.
### HIGH: Using dark: media prefix instead of .dark class
```tsx
// Wrong -- media-based dark mode, will not match DS behavior
<div className="dark:bg-gray-800">
// Correct -- use semantic tokens that auto-switch
<div className="bg-background">
// If you must target dark mode explicitly, the .dark class variant works:
<div className="dark:bg-brand-900">
// This works because the DS defines: @custom-variant dark (&:is(.dark *));
```
The design system uses class-based dark mode (`@custom-variant dark (&:is(.dark *))`), not `prefers-color-scheme`. Semantic tokens are the preferred path since they auto-switch without any `dark:` prefix.
### HIGH: Not importing theme CSS
```tsx
// Wrong -- components render unstyled or with wrong colors
import { Button } from "@loke/design-system/button";
// Correct -- import styles which includes theme tokens AND component styles
import "@loke/design-system/styles";
import { Button } from "@loke/design-system/button";
```
The styles import loads both the theme tokens (`:root` and `.dark` variables) and base layer styles. Without it, CSS custom properties are undefined and components fall back to browser defaults.
## See Also
- getting-started/SKILL.md -- initial CSS setup and @source directive
- layout/SKILL.md -- responsive props interact with Tailwind utilities (tension)