Guide

Building a Design System with Emotion.js and Design Tokens

A practical guide to architecting scalable design systems using Emotion.js, design tokens, and component composition patterns.

FramingUI Team12 min read

Emotion.js gives you powerful CSS-in-JS capabilities: the css prop for inline composition, styled for component creation, and excellent TypeScript support. But power without structure leads to inconsistency. A button using padding: 12px, a card using padding: 16px, and an input using padding: 0.75rem — all arbitrary, all impossible to maintain.

A design system built on design tokens eliminates this chaos. Tokens define your spacing scale, color palette, typography, and other primitives. Emotion provides the styling engine. Together, they create a foundation where every component pulls from the same source of truth.

This guide walks through building a production-ready design system with Emotion and tokens, from initial setup to component composition and team workflows.

Why Emotion for Design Systems

Framework-Agnostic with React Optimization

Emotion works with React, Preact, Vue, and vanilla JavaScript. For React specifically, the css prop and styled API offer ergonomic component authoring without sacrificing performance.

Composition Over Configuration

Emotion's css function lets you compose styles functionally:

import { css } from '@emotion/react';

const baseButton = css`
  padding: 12px 24px;
  border-radius: 8px;
`;

const primaryButton = css`
  ${baseButton}
  background: blue;
  color: white;
`;

This composability is perfect for design systems where components share base styles and extend them with variants.

TypeScript-First

Emotion's TypeScript support is excellent. Theme types propagate automatically, giving you autocomplete and type errors when referencing tokens.

Minimal Runtime Overhead

Emotion generates atomic CSS classes and deduplicates styles. Your bundle contains only the CSS you use, and runtime performance is competitive with static solutions.

Setting Up Token Infrastructure

Token Schema Design

Start with a structured token schema. Use nested objects for organization and semantic naming:

// tokens/primitives.js
export const primitives = {
  // Color palette
  blue: {
    50: '#EFF6FF',
    100: '#DBEAFE',
    500: '#3B82F6',
    600: '#2563EB',
    700: '#1D4ED8',
    900: '#1E3A8A',
  },
  gray: {
    50: '#F9FAFB',
    100: '#F3F4F6',
    500: '#6B7280',
    700: '#374151',
    900: '#111827',
  },
  red: {
    50: '#FEF2F2',
    500: '#EF4444',
    600: '#DC2626',
  },
  green: {
    50: '#F0FDF4',
    500: '#22C55E',
  },
  
  // Spacing scale (4px base)
  space: {
    0: '0',
    1: '4px',
    2: '8px',
    3: '12px',
    4: '16px',
    6: '24px',
    8: '32px',
    12: '48px',
    16: '64px',
  },
  
  // Typography
  fontSize: {
    xs: '12px',
    sm: '14px',
    base: '16px',
    lg: '18px',
    xl: '20px',
    '2xl': '24px',
    '3xl': '30px',
  },
  
  fontWeight: {
    normal: 400,
    medium: 500,
    semibold: 600,
    bold: 700,
  },
  
  lineHeight: {
    tight: 1.25,
    normal: 1.5,
    relaxed: 1.75,
  },
  
  // Radii
  radius: {
    none: '0',
    sm: '4px',
    md: '8px',
    lg: '12px',
    xl: '16px',
    full: '9999px',
  },
  
  // Shadows
  shadow: {
    xs: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
    sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
    base: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
    md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
    lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
  },
};

Semantic Token Layer

Map primitives to semantic usage:

// tokens/semantic.js
import { primitives } from './primitives';

export const semantic = {
  color: {
    // Interactive elements
    primary: {
      solid: primitives.blue[500],
      hover: primitives.blue[600],
      active: primitives.blue[700],
      subtle: primitives.blue[50],
    },
    
    // Destructive actions
    danger: {
      solid: primitives.red[500],
      hover: primitives.red[600],
      subtle: primitives.red[50],
    },
    
    // Success states
    success: {
      solid: primitives.green[500],
      subtle: primitives.green[50],
    },
    
    // Text hierarchy
    text: {
      primary: primitives.gray[900],
      secondary: primitives.gray[700],
      tertiary: primitives.gray[500],
      inverse: '#FFFFFF',
    },
    
    // Surfaces
    surface: {
      base: '#FFFFFF',
      raised: primitives.gray[50],
      overlay: primitives.gray[100],
    },
    
    // Borders
    border: {
      default: primitives.gray[200],
      subtle: primitives.gray[100],
      focus: primitives.blue[500],
    },
  },
  
  spacing: primitives.space,
  fontSize: primitives.fontSize,
  fontWeight: primitives.fontWeight,
  lineHeight: primitives.lineHeight,
  radius: primitives.radius,
  shadow: primitives.shadow,
};

Theme Configuration

Create the Emotion theme:

// tokens/theme.js
import { semantic } from './semantic';

export const theme = {
  ...semantic,
  
  // Animation tokens
  transition: {
    fast: '150ms ease',
    base: '200ms ease',
    slow: '300ms ease',
  },
  
  // Z-index scale
  zIndex: {
    base: 0,
    dropdown: 1000,
    sticky: 1100,
    modal: 1200,
    popover: 1300,
    tooltip: 1400,
  },
  
  // Breakpoints
  breakpoint: {
    sm: '640px',
    md: '768px',
    lg: '1024px',
    xl: '1280px',
  },
};

TypeScript Theme Types

Generate types for autocomplete and validation:

// tokens/theme.ts
import { theme } from './theme';

export type Theme = typeof theme;

// Emotion TypeScript declaration
declare module '@emotion/react' {
  export interface Theme extends Theme {}
}

Integrating Tokens with Emotion

ThemeProvider Setup

Wrap your app with ThemeProvider:

// App.jsx
import { ThemeProvider } from '@emotion/react';
import { theme } from './tokens/theme';

export function App() {
  return (
    <ThemeProvider theme={theme}>
      {/* your app */}
    </ThemeProvider>
  );
}

Using Tokens with the css Prop

Access theme tokens via the theme prop in css:

/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

function Button({ children }) {
  return (
    <button
      css={theme => css`
        padding: ${theme.spacing[3]} ${theme.spacing[6]};
        font-size: ${theme.fontSize.base};
        font-weight: ${theme.fontWeight.medium};
        border-radius: ${theme.radius.md};
        background: ${theme.color.primary.solid};
        color: white;
        border: none;
        cursor: pointer;
        transition: background ${theme.transition.fast};
        
        &:hover {
          background: ${theme.color.primary.hover};
        }
        
        &:active {
          background: ${theme.color.primary.active};
        }
      `}
    >
      {children}
    </button>
  );
}

Using Tokens with styled

For reusable components, use styled:

import styled from '@emotion/styled';

const Button = styled.button`
  padding: ${p => p.theme.spacing[3]} ${p => p.theme.spacing[6]};
  font-size: ${p => p.theme.fontSize.base};
  font-weight: ${p => p.theme.fontWeight.medium};
  border-radius: ${p => p.theme.radius.md};
  background: ${p => p.theme.color.primary.solid};
  color: white;
  border: none;
  cursor: pointer;
  transition: background ${p => p.theme.transition.fast};
  
  &:hover {
    background: ${p => p.theme.color.primary.hover};
  }
  
  &:active {
    background: ${p => p.theme.color.primary.active};
  }
`;

Building Design System Components

Button Component with Variants

import styled from '@emotion/styled';

const StyledButton = styled.button`
  /* Base styles */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: ${p => p.theme.spacing[2]};
  
  font-family: inherit;
  font-weight: ${p => p.theme.fontWeight.medium};
  line-height: ${p => p.theme.lineHeight.tight};
  
  border: none;
  border-radius: ${p => p.theme.radius.md};
  cursor: pointer;
  transition: all ${p => p.theme.transition.fast};
  
  /* Size variants */
  ${p => p.size === 'sm' && `
    padding: ${p.theme.spacing[2]} ${p.theme.spacing[4]};
    font-size: ${p.theme.fontSize.sm};
  `}
  
  ${p => (!p.size || p.size === 'md') && `
    padding: ${p.theme.spacing[3]} ${p.theme.spacing[6]};
    font-size: ${p.theme.fontSize.base};
  `}
  
  ${p => p.size === 'lg' && `
    padding: ${p.theme.spacing[4]} ${p.theme.spacing[8]};
    font-size: ${p.theme.fontSize.lg};
  `}
  
  /* Color variants */
  ${p => (!p.variant || p.variant === 'primary') && `
    background: ${p.theme.color.primary.solid};
    color: white;
    
    &:hover:not(:disabled) {
      background: ${p.theme.color.primary.hover};
    }
    
    &:active:not(:disabled) {
      background: ${p.theme.color.primary.active};
    }
  `}
  
  ${p => p.variant === 'danger' && `
    background: ${p.theme.color.danger.solid};
    color: white;
    
    &:hover:not(:disabled) {
      background: ${p.theme.color.danger.hover};
    }
  `}
  
  ${p => p.variant === 'outline' && `
    background: transparent;
    border: 1px solid ${p.theme.color.border.default};
    color: ${p.theme.color.text.primary};
    
    &:hover:not(:disabled) {
      background: ${p.theme.color.surface.raised};
    }
  `}
  
  ${p => p.variant === 'ghost' && `
    background: transparent;
    color: ${p.theme.color.text.primary};
    
    &:hover:not(:disabled) {
      background: ${p.theme.color.surface.raised};
    }
  `}
  
  /* Disabled state */
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
  
  /* Focus state */
  &:focus-visible {
    outline: 2px solid ${p => p.theme.color.border.focus};
    outline-offset: 2px;
  }
`;

export function Button({ variant = 'primary', size = 'md', children, ...props }) {
  return (
    <StyledButton variant={variant} size={size} {...props}>
      {children}
    </StyledButton>
  );
}

Usage:

<Button>Primary</Button>
<Button variant="danger">Delete</Button>
<Button variant="outline" size="sm">Cancel</Button>
<Button variant="ghost" size="lg">Learn More</Button>

Card Component

import styled from '@emotion/styled';

const CardContainer = styled.div`
  background: ${p => p.theme.color.surface.base};
  border: 1px solid ${p => p.theme.color.border.default};
  border-radius: ${p => p.theme.radius.lg};
  overflow: hidden;
  box-shadow: ${p => p.theme.shadow.sm};
  transition: all ${p => p.theme.transition.base};
  
  ${p => p.hoverable && `
    cursor: pointer;
    
    &:hover {
      box-shadow: ${p.theme.shadow.md};
      border-color: ${p.theme.color.border.focus};
    }
  `}
`;

const CardHeader = styled.div`
  padding: ${p => p.theme.spacing[6]};
  border-bottom: 1px solid ${p => p.theme.color.border.subtle};
`;

const CardTitle = styled.h3`
  margin: 0;
  font-size: ${p => p.theme.fontSize.lg};
  font-weight: ${p => p.theme.fontWeight.semibold};
  color: ${p => p.theme.color.text.primary};
`;

const CardBody = styled.div`
  padding: ${p => p.theme.spacing[6]};
`;

const CardFooter = styled.div`
  padding: ${p => p.theme.spacing[4]} ${p => p.theme.spacing[6]};
  background: ${p => p.theme.color.surface.raised};
  border-top: 1px solid ${p => p.theme.color.border.subtle};
`;

export function Card({ title, children, footer, hoverable, ...props }) {
  return (
    <CardContainer hoverable={hoverable} {...props}>
      {title && (
        <CardHeader>
          <CardTitle>{title}</CardTitle>
        </CardHeader>
      )}
      <CardBody>{children}</CardBody>
      {footer && <CardFooter>{footer}</CardFooter>}
    </CardContainer>
  );
}

Input Component

import styled from '@emotion/styled';

const InputWrapper = styled.div`
  display: flex;
  flex-direction: column;
  gap: ${p => p.theme.spacing[2]};
`;

const Label = styled.label`
  font-size: ${p => p.theme.fontSize.sm};
  font-weight: ${p => p.theme.fontWeight.medium};
  color: ${p => p.theme.color.text.primary};
`;

const StyledInput = styled.input`
  padding: ${p => p.theme.spacing[3]};
  font-size: ${p => p.theme.fontSize.base};
  line-height: ${p => p.theme.lineHeight.normal};
  color: ${p => p.theme.color.text.primary};
  background: ${p => p.theme.color.surface.base};
  border: 1px solid ${p => p.theme.color.border.default};
  border-radius: ${p => p.theme.radius.md};
  transition: all ${p => p.theme.transition.fast};
  
  &::placeholder {
    color: ${p => p.theme.color.text.tertiary};
  }
  
  &:hover:not(:disabled) {
    border-color: ${p => p.theme.color.border.focus};
  }
  
  &:focus {
    outline: none;
    border-color: ${p => p.theme.color.border.focus};
    box-shadow: 0 0 0 3px ${p => p.theme.color.primary.subtle};
  }
  
  &:disabled {
    background: ${p => p.theme.color.surface.raised};
    cursor: not-allowed;
    opacity: 0.6;
  }
  
  ${p => p.error && `
    border-color: ${p.theme.color.danger.solid};
    
    &:focus {
      box-shadow: 0 0 0 3px ${p.theme.color.danger.subtle};
    }
  `}
`;

const HelperText = styled.span`
  font-size: ${p => p.theme.fontSize.xs};
  color: ${p => p.error ? p.theme.color.danger.solid : p.theme.color.text.secondary};
`;

export function Input({ label, error, helperText, ...props }) {
  return (
    <InputWrapper>
      {label && <Label>{label}</Label>}
      <StyledInput error={!!error} {...props} />
      {(error || helperText) && (
        <HelperText error={!!error}>{error || helperText}</HelperText>
      )}
    </InputWrapper>
  );
}

Advanced Patterns

Responsive Design with Breakpoint Tokens

Create a responsive utility:

// utils/responsive.js
export function mq(breakpoint) {
  return `@media (min-width: ${breakpoint})`;
}

Use in components:

import styled from '@emotion/styled';
import { mq } from '../utils/responsive';

const Grid = styled.div`
  display: grid;
  gap: ${p => p.theme.spacing[4]};
  grid-template-columns: 1fr;
  
  ${p => mq(p.theme.breakpoint.md)} {
    grid-template-columns: repeat(2, 1fr);
  }
  
  ${p => mq(p.theme.breakpoint.lg)} {
    grid-template-columns: repeat(3, 1fr);
  }
`;

Dark Mode Support

Extend your theme with mode-specific tokens:

// tokens/themes.js
export const lightTheme = {
  ...baseTokens,
  color: {
    text: {
      primary: primitives.gray[900],
      secondary: primitives.gray[700],
    },
    surface: {
      base: '#FFFFFF',
      raised: primitives.gray[50],
    },
    border: {
      default: primitives.gray[200],
    },
  },
};

export const darkTheme = {
  ...baseTokens,
  color: {
    text: {
      primary: primitives.gray[50],
      secondary: primitives.gray[300],
    },
    surface: {
      base: primitives.gray[900],
      raised: primitives.gray[800],
    },
    border: {
      default: primitives.gray[700],
    },
  },
};

Toggle at runtime:

import { useState } from 'react';
import { ThemeProvider } from '@emotion/react';
import { lightTheme, darkTheme } from './tokens/themes';

export function App() {
  const [mode, setMode] = useState('light');
  const theme = mode === 'light' ? lightTheme : darkTheme;
  
  return (
    <ThemeProvider theme={theme}>
      <button onClick={() => setMode(mode === 'light' ? 'dark' : 'light')}>
        Toggle Mode
      </button>
      {/* app */}
    </ThemeProvider>
  );
}

Components automatically adapt because they reference semantic tokens.

Component Composition with css Prop

Compose styles inline for one-off customizations:

/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import { Button } from './Button';

function Hero() {
  return (
    <Button
      css={theme => css`
        width: 100%;
        font-size: ${theme.fontSize['2xl']};
        padding: ${theme.spacing[6]} ${theme.spacing[12]};
      `}
    >
      Get Started
    </Button>
  );
}

This preserves base Button styles and extends them without creating a new component.

Tooling and Workflow

Token Extraction from Design Tools

If you're using Figma, Sketch, or another design tool, export tokens as JSON and transform them:

{
  "color": {
    "primary": {
      "solid": { "value": "#3B82F6", "type": "color" }
    }
  },
  "spacing": {
    "4": { "value": "16px", "type": "dimension" }
  }
}

Transform script:

// scripts/generateTokens.js
const fs = require('fs');
const rawTokens = require('./tokens.json');

function flattenTokens(obj, prefix = '') {
  let result = {};
  for (const [key, val] of Object.entries(obj)) {
    const path = prefix ? `${prefix}.${key}` : key;
    if (val.value !== undefined) {
      result[key] = val.value;
    } else {
      result[key] = flattenTokens(val, path);
    }
  }
  return result;
}

const tokens = flattenTokens(rawTokens);
fs.writeFileSync(
  './src/tokens/generated.js',
  `export const tokens = ${JSON.stringify(tokens, null, 2)};`
);

Tools like FramingUI can automate this, syncing design and code tokens in real-time.

Linting for Token Usage

Enforce token usage with ESLint:

// .eslintrc.js
module.exports = {
  rules: {
    'no-restricted-syntax': [
      'error',
      {
        selector: 'TemplateLiteral[quasis.0.value.raw=/#[0-9A-Fa-f]{6}/]',
        message: 'Use theme.color tokens instead of hardcoded hex colors',
      },
    ],
  },
};

Storybook Integration

Document components with Storybook:

// Button.stories.jsx
import { Button } from './Button';

export default {
  title: 'Components/Button',
  component: Button,
};

export const Variants = () => (
  <div style={{ display: 'flex', gap: '16px' }}>
    <Button variant="primary">Primary</Button>
    <Button variant="danger">Danger</Button>
    <Button variant="outline">Outline</Button>
    <Button variant="ghost">Ghost</Button>
  </div>
);

export const Sizes = () => (
  <div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
    <Button size="sm">Small</Button>
    <Button size="md">Medium</Button>
    <Button size="lg">Large</Button>
  </div>
);

Real-World Application: Dashboard

/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Card } from './components/Card';
import { Button } from './components/Button';

const DashboardLayout = styled.div`
  display: grid;
  gap: ${p => p.theme.spacing[6]};
  padding: ${p => p.theme.spacing[8]};
  background: ${p => p.theme.color.surface.raised};
  min-height: 100vh;
`;

const Header = styled.header`
  display: flex;
  justify-content: space-between;
  align-items: center;
`;

const Title = styled.h1`
  margin: 0;
  font-size: ${p => p.theme.fontSize['3xl']};
  font-weight: ${p => p.theme.fontWeight.bold};
  color: ${p => p.theme.color.text.primary};
`;

const MetricGrid = styled.div`
  display: grid;
  gap: ${p => p.theme.spacing[4]};
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
`;

const MetricValue = styled.div`
  font-size: ${p => p.theme.fontSize['2xl']};
  font-weight: ${p => p.theme.fontWeight.semibold};
  color: ${p => p.theme.color.text.primary};
  margin-bottom: ${p => p.theme.spacing[1]};
`;

const MetricLabel = styled.div`
  font-size: ${p => p.theme.fontSize.sm};
  color: ${p => p.theme.color.text.secondary};
`;

export function Dashboard() {
  return (
    <DashboardLayout>
      <Header>
        <Title>Dashboard</Title>
        <Button>Export Report</Button>
      </Header>
      
      <MetricGrid>
        <Card>
          <MetricValue>12,345</MetricValue>
          <MetricLabel>Total Users</MetricLabel>
        </Card>
        <Card>
          <MetricValue>$45,231</MetricValue>
          <MetricLabel>Revenue</MetricLabel>
        </Card>
        <Card>
          <MetricValue>89%</MetricValue>
          <MetricLabel>Satisfaction</MetricLabel>
        </Card>
      </MetricGrid>
    </DashboardLayout>
  );
}

Every spacing, color, and typographic decision comes from tokens. Change theme.spacing[6] globally and the entire dashboard updates.

When to Use Emotion for Your Design System

Emotion works well when:

  • You need runtime theme switching (light/dark mode, user themes)
  • Your team prefers CSS-in-JS over utility classes or CSS Modules
  • You want strong TypeScript integration
  • Component composition and inline styling are important to your workflow

If you need zero-runtime CSS extraction, consider Vanilla Extract or Panda CSS. If you prefer utility-first styling, Tailwind with token configuration might be better. But for flexible, component-driven design systems with runtime theming, Emotion + tokens is a solid foundation.


A design system built on Emotion and design tokens gives you both power and predictability. Tokens centralize decisions. Emotion provides the styling engine. Together, they create a scalable foundation where consistency is automatic, not aspirational.

Ready to build with FramingUI?

Build consistent UI with AI-ready design tokens. No more hallucinated colors or spacing.

Try FramingUI
Share

Related Posts