Neem Design System
A single source of truth for all visual tokens. Every color, spacing value, shape, typography style, and motion curve
is defined once in tokens.json and consumed through a three-layer CSS custom property cascade:
Palette → Theme → Alias. Components only ever reference Layer 3 aliases.
Architecture — Three-Layer Token Cascade
The system prevents hardcoded values by enforcing a strict resolution chain. Components never touch palette or theme layers directly.
┌─────────────────────────────────────────────────────────────────────────┐
│ tokens.json (single source of truth) │
└────────┬────────────────────────────────────────────────────────────────┘
│ generates
▼
┌────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Layer 1: Palette │───▶│ Layer 2: Themes │───▶│ Layer 3: Alias │
│ palette.css │ │ themes.css │ │ aliases.css │
│ │ │ │ │ │
│ Raw hex values │ │ Brand decisions │ │ Semantic names │
│ --neem-palette- │ │ --neem-primary │ │ --neem-surface- │
│ cyan20: #6CD2FF │ │ : var(--neem- │ │ primary: var( │
│ │ │ palette-cyan20) │ │ --neem-primary│
│ Defined once. │ │ One block per theme. │ │ ) │
│ Never referenced │ │ Adding a new theme = │ │ Components ONLY│
│ by components. │ │ adding one block. │ │ read these. │
└────────────────────┘ └──────────────────────┘ └──────────────────┘
Theme switching = changing data-neem-theme attribute on <body>
Layer 2 resolves differently → Layer 3 aliases auto-update → Components re-paint
How to load
<!-- Option A: Single bundle import --> <link rel="stylesheet" href="neem/theme.css" /> <!-- theme.css contains: --> @import "./palette.css"; /* Layer 1 */ @import "./themes.css"; /* Layer 2 */ @import "./aliases.css"; /* Layer 3 */ <!-- Then load component stylesheets --> <link rel="stylesheet" href="neem/components/button.css" /> <!-- Set the active theme --> <body data-neem-theme="blue"> <!-- or "green" / "dark" -->
tokens.json — Single source of truth
All tokens originate from this JSON file. CSS files, Ruby helpers, and any future platform (iOS, Android) read from it.
{
"palette": {
"cyan20": "#6CD2FF",
"green20": "#006C45",
"navy30": "#19355E",
...
},
"spacing": {
"extraExtraSmall": "4px",
"extraSmall": "8px",
"small": "16px",
"medium": "24px",
"large": "32px",
"extraLarge": "48px",
"extraExtraLarge": "64px"
},
"themes": {
"blue": { "surface": { "primary": "cyan20", "onPrimary": "cyan50" } },
"green": { "surface": { "primary": "green20", "onPrimary": "neutral10" } },
"dark": { "surface": { "primary": "darkPrimary", "onPrimary": "darkOnPrimary" } }
}
}
Layer 1 — Palette
Raw color values defined once. Never referenced by components directly. If a color changes, update it here only.
Cyan
Green
Navy
Red / Error
Yellow
Neutral
Dark Mode
Code — palette.css
:root {
--neem-palette-cyan10: #BFE9FF;
--neem-palette-cyan20: #6CD2FF;
--neem-palette-cyan30: #187BC2;
--neem-palette-cyan40: #004D65;
--neem-palette-cyan50: #003547;
--neem-palette-green5: #EDFFF3;
--neem-palette-green10: #D4FAE0;
--neem-palette-green20: #006C45;
--neem-palette-green30: #124854;
--neem-palette-navy10: #EAF9FF;
--neem-palette-navy20: #186D92;
--neem-palette-navy30: #19355E;
--neem-palette-red10: #FFDAD6;
--neem-palette-red20: #BA1A1A;
--neem-palette-red30: #410002;
--neem-palette-neutral10: #FFFFFF;
--neem-palette-neutral20: #F7F7F7;
--neem-palette-neutral30: #E5E5E5;
/* ... */
}
Layer 2 — Themes
Each theme maps the same semantic token names to different palette values. Adding a new theme = adding one CSS block. No aliases or components change.
Live Theme Aliases (changes with theme toggle above)
Code — themes.css
/* Blue (default) */
[data-neem-theme="blue"], :root {
--neem-primary: var(--neem-palette-cyan20);
--neem-on-primary: var(--neem-palette-cyan50);
--neem-secondary: var(--neem-palette-navy30);
--neem-on-secondary: var(--neem-palette-neutral10);
--neem-surface: var(--neem-palette-neutral10);
--neem-on-surface: var(--neem-palette-neutral70);
--neem-shadow: var(--neem-palette-shadow-light);
}
/* Green — only the brand colors differ */
[data-neem-theme="green"] {
--neem-primary: var(--neem-palette-green20);
--neem-on-primary: var(--neem-palette-neutral10);
--neem-secondary-container: var(--neem-palette-green5);
}
/* Dark — surfaces and text flip for dark backgrounds */
[data-neem-theme="dark"] {
--neem-primary: var(--neem-palette-dark-primary);
--neem-on-primary: var(--neem-palette-dark-on-primary);
--neem-surface: var(--neem-palette-dark-surface);
--neem-on-surface: var(--neem-palette-dark-on-surface);
--neem-shadow: var(--neem-palette-shadow-dark);
}
Layer 3 — Semantic Aliases
Components ONLY reference these tokens. They never change between themes — the cascade handles resolution automatically.
Surface Aliases
Text Aliases
Border Aliases
Icon Aliases
Code — aliases.css (excerpt)
:root {
/* Surface */
--neem-surface-primary: var(--neem-primary);
--neem-surface-disabled-container: var(--neem-surface-container-high);
--neem-surface-error: var(--neem-error);
/* Text */
--neem-text-on-primary: var(--neem-on-primary);
--neem-text-secondary: var(--neem-secondary);
--neem-text-disabled: var(--neem-surface-container-highest);
/* Border */
--neem-border-secondary: var(--neem-secondary);
--neem-border-disabled: var(--neem-surface-container-high);
/* Icon */
--neem-icon-success: var(--neem-palette-green20); /* always green */
}
Typography
All type styles are tokenized. Components reference --neem-typography-* variables — never raw px values.
Font Weights
Code — Typography tokens
:root {
--neem-typography-display-small-size: 36px;
--neem-typography-display-small-line-height: 48px;
--neem-typography-headline-medium-size: 28px;
--neem-typography-headline-medium-line-height:40px;
--neem-typography-headline-small-size: 20px;
--neem-typography-body-medium-size: 14px;
--neem-typography-body-small-size: 12px;
--neem-typography-label-large-size: 16px;
--neem-typography-label-large-letter-spacing: 0.8px;
--neem-typography-code-size: 13px;
--neem-typography-weights-normal: 400;
--neem-typography-weights-bold: 700;
}
Spacing
A linear spacing scale used for padding, margins, gaps, and component dimensions. Mirrors Kotlin NeemSpacing.
| Token | Value | Visual |
|---|---|---|
| --neem-spacing-none | 0px | |
| --neem-spacing-border | 1px | |
| --neem-spacing-extra-extra-small | 4px | |
| --neem-spacing-extra-small | 8px | |
| --neem-spacing-small | 16px | |
| --neem-spacing-medium | 24px | |
| --neem-spacing-large | 32px | |
| --neem-spacing-extra-large | 48px | |
| --neem-spacing-extra-extra-large | 64px | |
| --neem-spacing-extra-extra-extra-large | 80px |
Shapes (Border Radius)
Mirrors Kotlin NeemShapes. Used for buttons, cards, inputs, badges.
| Token | Value | Visual |
|---|---|---|
| --neem-shapes-extra-small | 2px | |
| --neem-shapes-small | 4px | |
| --neem-shapes-medium | 8px | |
| --neem-shapes-large | 16px | |
| --neem-shapes-extra-large | 32px | |
| --neem-shapes-full | 9999px |
Border Width
Two border widths for standard and emphasized borders.
| Token | Value | Visual |
|---|---|---|
| --neem-border-width-thin | 1px | |
| --neem-border-width-bold | 2px |
Motion
Duration and easing tokens for consistent animations across all components.
| Token | Value |
|---|---|
| --neem-motion-duration-short | 150ms |
| --neem-motion-duration-medium | 300ms |
| --neem-motion-duration-long | 500ms |
| --neem-motion-easing-standard | cubic-bezier(0.4, 0, 0.2, 1) |
Live Motion Demo
Component — Button
The button component exists across all frameworks. Every visual value comes from tokens. Toggle the theme above to see all states update instantly.
All States
Token Mapping — What the button reads
/* Every button property maps to a Layer 3 alias token */
.neem-button {
height: var(--neem-spacing-extra-extra-large); /* 64px */
padding: 0 var(--neem-spacing-medium); /* 24px */
border-radius: var(--neem-shapes-large); /* 16px */
font-size: var(--neem-typography-label-large-size); /* 16px */
font-weight: var(--neem-typography-weights-bold); /* 700 */
letter-spacing: var(--neem-typography-label-large-letter-spacing); /* 0.8px */
transition: all var(--neem-motion-duration-short) /* 150ms */
var(--neem-motion-easing-standard);
}
.neem-button--primary {
background: var(--neem-surface-primary); /* cyan20 → green20 → darkPrimary */
color: var(--neem-text-on-primary); /* cyan50 → neutral10 → darkOnPrimary */
}
.neem-button--secondary {
border: var(--neem-border-width-bold) solid var(--neem-border-secondary);
color: var(--neem-text-secondary);
}
.neem-button:disabled {
background: var(--neem-surface-disabled-container);
color: var(--neem-text-disabled);
border-color: var(--neem-border-disabled);
}
Framework Integrations
The same token system works identically across every framework. See each demo page for full implementations.
Available Implementations
| Framework | Approach | Demo |
|---|---|---|
| Lit 3 | Shadow DOM custom element, CSS vars pierce boundary | View Demo |
| BEM (ViewComponent) | Plain <button> + global CSS classes, zero JS | View Demo |
| Hybrid (VC + Lit) | Ruby renders <neem-button>, Lit hydrates client-side | View Demo |
| Tailwind + ViewComponent | Utility classes with arbitrary token references | View Demo |
| React 19 | Functional component + BEM classes + shared button.css | View Demo |
| Vue 3 | Composition API SFC, no <style scoped> needed | View Demo |
Principle — Framework swappability
Because every framework reads from the same token cascade, you can swap the internal rendering engine (Lit → React, BEM → Tailwind) without affecting the design. The contract is the CSS custom properties, not the framework.
/* This is the contract. Every framework reads these same variables. */
/* Change the framework? The variables don't change. */
/* Change the theme? The framework doesn't change. */
Component CSS → reads → Layer 3 Aliases → resolves via → Layer 2 Theme → Layer 1 Palette
(--neem-surface-*) ([data-neem-theme]) (--neem-palette-*)
Never changes. Swappable. Defined once.