Design Core
Framework-agnostic design tokens, CSS custom properties, dynamic theming, and a pre-built component library.
Design Core
@web-loom/design-core is a zero-dependency, framework-agnostic design token system. It provides a single source of truth for every visual decision in your application — colors, typography, spacing, shadows, motion, and more — with automatic CSS custom property generation, runtime theming, and an optional pre-built component library.
Overview
- Design tokens — 15 categories of tokens following the Design Token Community Group format
- CSS custom properties — all tokens are pre-generated as CSS variables, ready to import
- Dynamic theming — runtime light / dark / high-contrast switching via
data-theme - Pre-built component library — 20+ headless CSS components (button, input, modal, card, …)
- TypeScript — every category is fully typed with generated type definitions
- Zero dependencies — no runtime dependencies; pure CSS + async utilities
Installation
npm install @web-loom/design-coreQuick Start
Import the full design system
The fastest path: import a single CSS file that includes all token variables and base resets.
/* In your global CSS entry point */
@import '@web-loom/design-core/design-system';Or in JavaScript:
import '@web-loom/design-core/design-system';Import individual token categories
Import only what you need to keep bundle sizes small:
import '@web-loom/design-core/css/colors.css';
import '@web-loom/design-core/css/spacing.css';
import '@web-loom/design-core/css/typography.css';Available categories: borders, breakpoints, colors, cursor-styles, focus-rings, gradients, opacity, radii, shadows, sizing, spacing, timing, transitions, typography, z-index.
Use tokens in CSS
Once imported, every token is available as a CSS custom property:
.card {
background: var(--color-neutral-white);
color: var(--color-neutral-gray-900);
padding: var(--spacing-6);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
transition: box-shadow var(--transition-duration-medium)
var(--transition-timingFunction-ease-out);
}
[data-theme='dark'] .card {
background: var(--color-themed-dark-background);
color: var(--color-themed-dark-text);
}Design Token Reference
All tokens follow the Design Token Community Group format: { value, type, description? }. References like {color.neutral.white.value} are resolved automatically at build time.
Colors
CSS variable prefix: --color-*
Base palette
- primary —
--color-base-primary—#1E40AF - secondary —
--color-base-secondary—#64748B - success —
--color-base-success—#10B981 - warning —
--color-base-warning—#F59E0B - danger —
--color-base-danger—#EF4444 - accent1 —
--color-base-accent1—#0CD4F3 - accent2 —
--color-base-accent2—#F32B0C
Neutral scale
- white —
--color-neutral-white—#FFFFFF - gray-50 —
--color-neutral-gray-50—#F9FAFB - gray-100 —
--color-neutral-gray-100—#F3F4F6 - gray-200 —
--color-neutral-gray-200—#E5E7EB - gray-300 —
--color-neutral-gray-300—#D1D5DB - gray-400 —
--color-neutral-gray-400—#9CA3AF - gray-500 —
--color-neutral-gray-500—#6B7280 - gray-600 —
--color-neutral-gray-600—#4B5563 - gray-700 —
--color-neutral-gray-700—#374151 - gray-800 —
--color-neutral-gray-800—#1F2937 - gray-900 —
--color-neutral-gray-900—#111827 - black —
--color-neutral-black—#000000
Themed (semantic) colors
These tokens carry a light / dark / high-contrast variant and respond to the data-theme attribute automatically.
--color-themed-light-background—#FFFFFF--color-themed-light-text—#000000--color-themed-dark-background— defined asvar(--color-neutral-900), a variable the stylesheet doesn't define (the gray scale is--color-neutral-gray-*), so it currently resolves to nothing; set it yourself (e.g.var(--color-neutral-gray-900),#111827) if you rely on it--color-themed-dark-text—#FFFFFF--color-themed-high-contrast-background—#000000--color-themed-high-contrast-text—#FFFFFF
Alpha colors
--color-alpha-primary-50—rgba(30, 64, 175, 0.5)--color-alpha-primary-100—rgba(30, 64, 175, 1)--color-alpha-black-50—rgba(0, 0, 0, 0.5)
Spacing
CSS variable prefix: --spacing-*
A 4-point grid scale (4 px base unit) plus semantic aliases:
--spacing-0—0px--spacing-1—4px--spacing-2—8px--spacing-3—12px--spacing-4—16px--spacing-5—20px--spacing-6—24px--spacing-7—28px--spacing-8—32px--spacing-9—36px--spacing-10—40px
Semantic aliases (resolve to the scale above):
--spacing-gutter—16px(→ spacing.4)--spacing-padding-sm—8px/--spacing-padding-md—16px/--spacing-padding-lg—24px--spacing-margin-sm—8px/--spacing-margin-md—16px/--spacing-margin-lg—24px
Typography
CSS variable prefix: --typography-*
Font families
--typography-font-family-base—'Inter', sans-serif--typography-font-family-heading—'Poppins', sans-serif
Font sizes
--typography-font-size-xs—12px--typography-font-size-sm—14px--typography-font-size-md—16px--typography-font-size-lg—18px--typography-font-size-xl—20px--typography-font-size-2xl—24px--typography-font-size-3xl—30px
Font weights
--typography-font-weight-light—300--typography-font-weight-regular—400--typography-font-weight-medium—500--typography-font-weight-bold—700--typography-font-weight-extrabold—800
Line heights
--typography-line-height-tight—1.25--typography-line-height-normal—1.5--typography-line-height-loose—2
Letter spacing
--typography-letter-spacing-tight—-0.05em--typography-letter-spacing-normal—normal--typography-letter-spacing-wide—0.05em
Text transform
--typography-text-case-uppercase—uppercase--typography-text-case-lowercase—lowercase--typography-text-case-capitalize—capitalize
Shadows
CSS variable prefix: --shadow-*
--shadow-xs—0 1px 2px 0 rgba(0,0,0,0.05)— subtle card lift--shadow-sm— dual-layer small shadow — default card--shadow-md—0 4px 6px -1px rgba(0,0,0,0.1), …— elevated panel--shadow-lg—0 10px 15px -3px rgba(0,0,0,0.1), …— floating element--shadow-xl—0 20px 25px -5px rgba(0,0,0,0.1), …— modal / dialog--shadow-inner—inset 0 2px 4px 0 rgba(0,0,0,0.06)— pressed / well state--shadow-focus—0 0 0 3px rgba(59,130,246,0.5)— focus ring--shadow-md-top— upward-facing medium shadow — bottom sheets
Border Radius
CSS variable prefix: --radius-*
--radius-sm—4px--radius-md—8px--radius-lg—16px--radius-full—9999px(pill shape)
Z-Index
CSS variable prefix: --zIndex-*
--zIndex-auto—auto— browser default--zIndex-0—0— reset stacking context--zIndex-10—10— base elements--zIndex-20—20— dropdowns--zIndex-30—30— fixed / sticky headers--zIndex-40—40— modals--zIndex-50—50— tooltips / popovers--zIndex-max—9999— highest layer (overlays, toasts)
Breakpoints
CSS variable prefix: --breakpoint-*
Mobile-first breakpoints matching common device widths:
--breakpoint-xs—0px--breakpoint-sm—640px— tablets--breakpoint-md—768px— small laptops--breakpoint-lg—1024px— desktops--breakpoint-xl—1280px— large desktops--breakpoint-2xl—1536px— wide screens
Media query helpers available as token values:
@media (min-width: 768px) { /* breakpoint.md */ }
@media (orientation: portrait) { /* breakpoint.orientation.portrait */ }
@media (prefers-reduced-motion: reduce) { /* transition.motion.prefersReducedMotion */ }Transitions
CSS variable prefix: --transition-*
Durations:
--transition-duration-fast—150ms--transition-duration-medium—300ms--transition-duration-slow—500ms
Timing functions:
--transition-timingFunction-linear—linear--transition-timingFunction-ease-in—cubic-bezier(0.4, 0, 1, 1)--transition-timingFunction-ease-out—cubic-bezier(0, 0, 0.2, 1)--transition-timingFunction-ease-in-out—cubic-bezier(0.4, 0, 0.2, 1)
Delays:
--transition-delay-none—0ms--transition-delay-short—100ms--transition-delay-long—200ms
Always respect prefers-reduced-motion:
.animated {
transition: transform var(--transition-duration-medium)
var(--transition-timingFunction-ease-out);
}
@media (prefers-reduced-motion: reduce) {
.animated {
transition: none;
}
}Opacity
CSS variable prefix: --opacity-*
--opacity-0—0--opacity-25—0.25--opacity-50—0.5--opacity-75—0.75--opacity-100—1--opacity-disabled—0.5— use on disabled UI elements--opacity-muted—0.75— use on muted / secondary text
button:disabled {
opacity: var(--opacity-disabled);
cursor: not-allowed;
}CSS Custom Properties API
The utility functions let you work with tokens programmatically when CSS alone isn't enough.
pathToCssVar(path)
Converts a dot-path token name to a CSS custom property name.
import { pathToCssVar } from '@web-loom/design-core/utils';
pathToCssVar('color.base.primary'); // '--color-base-primary'
pathToCssVar('spacing.4'); // '--spacing-4'
pathToCssVar('typography.font.size.lg'); // '--typography-font-size-lg'getTokenVar(path)
Returns the full var(--...) reference string, ready to use in inline styles or JS-driven CSS.
import { getTokenVar } from '@web-loom/design-core/utils';
getTokenVar('color.base.primary'); // 'var(--color-base-primary)'
getTokenVar('shadow.md'); // 'var(--shadow-md)'getSafeTokenVar(path)
Async version of getTokenVar — validates the path with getTokenValue before returning var(--<path with dots replaced by dashes>). Returns undefined if the path is invalid.
Token paths start with the token file name (colors, spacing, shadows, …) followed by the JSON path inside it. Because the generated variable name includes that prefix, the result doesn't match the variable names in the shipped CSS files (--color-base-primary):
import { getSafeTokenVar } from '@web-loom/design-core/utils';
const ref = await getSafeTokenVar('colors.color.base.primary'); // 'var(--colors-color-base-primary)'
const bad = await getSafeTokenVar('color.base.primary'); // undefined — missing the file prefixgetTokenValue(path)
Resolves a token to its raw value (async, cached after first call).
import { getTokenValue } from '@web-loom/design-core/utils';
const primary = await getTokenValue('colors.color.base.primary'); // '#1E40AF'
const padding = await getTokenValue('spacing.spacing.4'); // '16px'
const shadow = await getTokenValue('shadows.shadow.md'); // '0 4px 6px -1px ...'generateCssVariablesMap()
Returns a flat Record<string, TokenValue> of every CSS variable and its resolved value. Useful for server-side rendering or generating custom stylesheets.
import { generateCssVariablesMap } from '@web-loom/design-core/utils';
const map = await generateCssVariablesMap();
// {
// '--colors-color-base-primary': '#1E40AF',
// '--spacing-spacing-4': '16px',
// ...
// }
// Keys include the token-file prefix, so they differ from the shipped CSS files' names.generateCssVariablesString(selector?)
Generates a complete CSS rule string. Defaults to :root.
import { generateCssVariablesString } from '@web-loom/design-core/utils';
const css = await generateCssVariablesString(':root');
// ':root { --color-base-primary: #1E40AF; --spacing-4: 16px; ... }'
// Inject into the document
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);getAllTokens()
Returns the full resolved token tree (all 15 categories, deeply nested).
import { getAllTokens } from '@web-loom/design-core/utils';
const tokens = await getAllTokens();
console.log(tokens.colors.color.base.primary); // '#1E40AF' — top-level keys are the token file namesTheming
Built-in themes
The base CSS ships with three themes controlled by data-theme on the <html> element. Themed tokens (--color-themed-*-background, --color-themed-*-text) switch values automatically via CSS — no JavaScript required.
<html data-theme="light"> <!-- default -->
<html data-theme="dark">
<html data-theme="high-contrast">createTheme(name, overrides)
Creates a theme object by merging token overrides onto the base set.
import { createTheme } from '@web-loom/design-core/utils';
const brandTheme = createTheme('brand', {
color: {
base: {
primary: { value: '#7C3AED' },
secondary: { value: '#A78BFA' },
},
},
});applyTheme(theme, applyToRoot?)
Generates and injects a <style> element containing the theme's CSS variables. By default the styles are scoped to [data-theme="<name>"]; pass true to override :root instead.
import { applyTheme } from '@web-loom/design-core/utils';
await applyTheme(brandTheme); // scoped to [data-theme="brand"]
await applyTheme(brandTheme, true); // overrides :rootsetTheme(themeName)
Activates a theme by setting data-theme on <html>. The pre-injected CSS takes effect immediately — no re-render required.
import { setTheme } from '@web-loom/design-core/utils';
setTheme('dark');
setTheme('brand');
setTheme('light');getCurrentTheme()
Returns the currently active theme name, or null if no data-theme attribute is set.
import { getCurrentTheme } from '@web-loom/design-core/utils';
getCurrentTheme(); // 'dark' | 'light' | 'brand' | nullFull theming workflow
import { createTheme, applyTheme, setTheme } from '@web-loom/design-core/utils';
// 1. Define custom themes at app startup
const darkTheme = createTheme('dark', {
color: {
themed: {
dark: {
background: { value: '#0a0a0a' },
text: { value: '#f5f5f5' },
},
},
},
});
// 2. Inject their CSS once, early in the app lifecycle
await applyTheme(darkTheme);
// 3. Switch at runtime — instant, CSS-only
setTheme('dark');
setTheme('light');Pre-built Component Library
Import @web-loom/design-core/design-system to get a lightweight CSS component library built entirely on top of the design tokens. All components use var(--...) references, so they automatically respond to theme changes.
@import '@web-loom/design-core/design-system';Components
Forms — button, checkbox, input, radio-group, select, switch, textarea
Display — avatar, badge, card, list, table
Layout — container, footer, page-content, page-header
Navigation — navigation-bar, sidebar, tabs
Overlays — modal, toast, tooltip
Utility — loader (spinner)
Usage examples
<!-- Buttons -->
<button class="btn btn-primary">Save</button>
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-danger">Delete</button>
<button class="btn btn-primary" disabled>Disabled</button>
<!-- Card -->
<div class="card">
<div class="card-header">
<h3>Task Details</h3>
</div>
<div class="card-body">
<p>Card content here.</p>
</div>
</div>
<!-- Badges -->
<span class="badge badge-success">Active</span>
<span class="badge badge-warning">Pending</span>
<span class="badge badge-danger">Failed</span>
<!-- Input -->
<div class="input-group">
<label class="form-label" for="name">Full name</label>
<input class="input" id="name" type="text" placeholder="Alice Smith" />
</div>
<!-- Loader -->
<div class="loader"></div> <!-- sizes: loader-sm, loader-lg; variant: loader-dots -->Dark mode works automatically — just set data-theme="dark" on <html> and all component classes switch palettes with no extra class names needed.
Framework Integration
React
A minimal hook to read the current theme and expose a toggle:
import { useEffect, useState, useCallback } from 'react';
import {
setTheme,
getCurrentTheme,
applyTheme,
createTheme,
} from '@web-loom/design-core/utils';
const darkTheme = createTheme('dark', {});
export function useTheme() {
const [theme, setThemeState] = useState<string>(
() => getCurrentTheme() ?? 'light',
);
useEffect(() => {
applyTheme(darkTheme);
}, []);
const toggle = useCallback(() => {
const next = theme === 'light' ? 'dark' : 'light';
setTheme(next);
setThemeState(next);
}, [theme]);
return { theme, toggle };
}Pairing with inline styles using token variables:
import { getTokenVar } from '@web-loom/design-core/utils';
function HighlightBox({ children }: { children: React.ReactNode }) {
return (
<div
style={{
background: getTokenVar('color.base.primary'),
color: getTokenVar('color.neutral.white'),
padding: getTokenVar('spacing.4'),
borderRadius: getTokenVar('radius.md'),
}}
>
{children}
</div>
);
}Vue 3
// composables/useTheme.ts
import { ref, onMounted } from 'vue';
import {
setTheme,
getCurrentTheme,
applyTheme,
createTheme,
} from '@web-loom/design-core/utils';
const darkTheme = createTheme('dark', {});
export function useTheme() {
const theme = ref(getCurrentTheme() ?? 'light');
onMounted(async () => {
await applyTheme(darkTheme);
});
function toggle() {
const next = theme.value === 'light' ? 'dark' : 'light';
setTheme(next);
theme.value = next;
}
return { theme, toggle };
}Angular
import { Injectable, signal, effect } from '@angular/core';
import {
setTheme,
getCurrentTheme,
applyTheme,
createTheme,
} from '@web-loom/design-core/utils';
const darkTheme = createTheme('dark', {});
@Injectable({ providedIn: 'root' })
export class ThemeService {
readonly theme = signal<string>(getCurrentTheme() ?? 'light');
constructor() {
applyTheme(darkTheme);
effect(() => setTheme(this.theme()));
}
toggle() {
this.theme.set(this.theme() === 'light' ? 'dark' : 'light');
}
}Vanilla JavaScript
import '@web-loom/design-core/design-system';
import { applyTheme, createTheme, setTheme } from '@web-loom/design-core/utils';
const darkTheme = createTheme('dark', {});
await applyTheme(darkTheme);
document.getElementById('theme-toggle').addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
setTheme(current === 'dark' ? 'light' : 'dark');
});Pairing with MVVM Core
ViewModel-driven theming
Keep theme state in a ViewModel rather than scattered across components:
import { signal, computed, effect } from '@web-loom/signals-core';
import {
setTheme,
getCurrentTheme,
applyTheme,
createTheme,
} from '@web-loom/design-core/utils';
const themes = {
dark: createTheme('dark', {}),
light: createTheme('light', {}),
};
export class ThemeViewModel {
private _theme = signal<'light' | 'dark'>(
(getCurrentTheme() as 'light' | 'dark') ?? 'light',
);
readonly theme = this._theme.asReadonly();
readonly isDark = computed(() => this._theme.get() === 'dark');
private _effectHandle = effect(() => setTheme(this._theme.get()));
constructor() {
Promise.all([applyTheme(themes.light), applyTheme(themes.dark)]);
}
setTheme(theme: 'light' | 'dark') {
this._theme.set(theme);
}
toggle() {
this._theme.update((t) => (t === 'light' ? 'dark' : 'light'));
}
dispose() {
this._effectHandle.dispose();
}
}Model-layer token access
When building user-customizable branding, resolve tokens in the Model and expose them as readonly signals:
import { signal } from '@web-loom/signals-core';
import { getTokenValue } from '@web-loom/design-core/utils';
export class BrandingModel {
private _primaryColor = signal('#1E40AF');
readonly primaryColor = this._primaryColor.asReadonly();
async loadDefaults() {
const value = await getTokenValue('colors.color.base.primary');
if (value) this._primaryColor.set(String(value));
}
setPrimaryColor(hex: string) {
this._primaryColor.set(hex);
}
}TypeScript
All token category types are exported from @web-loom/design-core/types:
import type {
DesignTokenValue,
ColorToken,
SpacingToken,
TypographyToken,
ShadowToken,
RadiusToken,
BorderToken,
BreakpointToken,
ZIndexToken,
OpacityToken,
TimingToken,
TransitionToken,
GradientToken,
FocusRingToken,
CursorToken,
SizingToken,
} from '@web-loom/design-core/types';The root interface that every token follows:
interface DesignTokenValue<T = string> {
value: T;
type: string;
description?: string;
}Utility types from @web-loom/design-core/utils:
import type {
TokenValue,
TokenGroup,
DesignTokens,
Theme,
} from '@web-loom/design-core/utils';Best Practices
- Import once. Load
@web-loom/design-core/design-system(or individual CSS files) at the application entry point — never inside components. - Prefer CSS variables over utility functions in CSS.
var(--spacing-4)in a stylesheet is zero runtime cost. Only reach forgetTokenVar/getTokenValuewhen you need token values in JavaScript logic. - Use semantic tokens for themed values. Prefer
--color-themed-light-backgroundover hardcoded hex values so theme switching works automatically without extra CSS. - Call
applyThemeduring app initialization. It reuses one<style>element per theme name (looked up by id), so repeated calls for the same theme replace rather than duplicate the injected CSS — but there's no reason to call it more than once per theme. - Scope custom themes. When
applyToRootisfalse(the default) each theme only activates whendata-theme="<name>"is set, so multiple themes can coexist in the stylesheet without conflict. - Respect motion preferences. Use a
@media (prefers-reduced-motion: reduce)block to disable animations for users who prefer reduced motion. - Pair with Signals Core for reactive theming. Drive
setThemefrom a signal so the DOM, derived computed values, and framework views all stay in sync automatically.