The pain this guide solves

Your styled-components theme object is a 200-line file of hardcoded hex values someone wrote two years ago. Dark mode is a second object that's 60% copy-pasted. When the brand color changes, you update both objects by hand and hope you didn't miss anything.

salt-theme-gen with CSS-in-JS

The connection

CSS-in-JS libraries accept a theme object that flows through ThemeProvider to every styled component. salt-theme-gen outputs exactly that — a typed GeneratedThemeMode object with every color, spacing value, and radius your components need.

The integration is: replace your handwritten theme object with theme.light (and theme.dark).


styled-components

Install

npm install salt-theme-gen styled-components
npm install --save-dev @types/styled-components

Theme provider setup

Create src/theme/index.ts:

import { generateTheme } from 'salt-theme-gen';

export const theme = generateTheme({
  preset: 'ocean',
  spacing: 'default',
  radius: 'default',
  fontSize: 'default',
});

Create src/theme/styled.d.ts to type the useTheme() hook:

// src/theme/styled.d.ts
import type { GeneratedThemeMode } from 'salt-theme-gen';
import 'styled-components';

declare module 'styled-components' {
  export interface DefaultTheme extends GeneratedThemeMode {}
}

This tells styled-components that your theme object is a GeneratedThemeMode — every useTheme() call returns fully typed tokens.

Wrap your app in App.tsx:

import { useState } from 'react';
import { ThemeProvider } from 'styled-components';
import { theme } from './theme';

export function App() {
  const [isDark, setIsDark] = useState(false);
  const mode = isDark ? theme.dark : theme.light;

  return (
    <ThemeProvider theme={mode}>
      <GlobalStyles />
      {/* your app */}
    </ThemeProvider>
  );
}

Global styles

import { createGlobalStyle } from 'styled-components';

const GlobalStyles = createGlobalStyle`
  *, *::before, *::after { box-sizing: border-box; }

  body {
    margin: 0;
    background-color: ${({ theme }) => theme.colors.background};
    color: ${({ theme }) => theme.colors.text};
    font-size: ${({ theme }) => theme.fontSizes.md}px;
    font-family: system-ui, -apple-system, sans-serif;
    line-height: 1.6;
    transition: background-color 0.2s, color 0.2s;
  }
`;

Styled components with tokens

import styled from 'styled-components';

export const Button = styled.button<{ intent?: 'primary' | 'danger' | 'success' }>`
  background: ${({ theme, intent = 'primary' }) => theme.colors[intent]};
  color: ${({ theme, intent = 'primary' }) =>
    theme.colors[`on${intent.charAt(0).toUpperCase()}${intent.slice(1)}` as keyof typeof theme.colors]
  };
  border: none;
  border-radius: ${({ theme }) => theme.radius.md}px;
  padding: ${({ theme }) => theme.spacing.sm}px ${({ theme }) => theme.spacing.lg}px;
  font-size: ${({ theme }) => theme.fontSizes.md}px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s;

  &:hover {
    background: ${({ theme, intent = 'primary' }) =>
      theme.states[intent as keyof typeof theme.states].hover
    };
  }

  &:focus-visible {
    outline: 2px solid ${({ theme }) => theme.states.primary.focused};
    outline-offset: 2px;
  }

  &:disabled {
    background: ${({ theme }) => theme.states.primary.disabled};
    cursor: not-allowed;
  }
`;

export const Card = styled.div`
  background: ${({ theme }) => theme.surfaceElevation.card};
  border: 1px solid ${({ theme }) => theme.colors.border};
  border-radius: ${({ theme }) => theme.radius.lg}px;
  padding: ${({ theme }) => theme.spacing.xl}px;
`;

export const Heading = styled.h2`
  font-size: ${({ theme }) => theme.fontSizes.xl}px;
  font-weight: 700;
  color: ${({ theme }) => theme.colors.text};
  margin: 0 0 ${({ theme }) => theme.spacing.md}px;
`;

export const Muted = styled.p`
  font-size: ${({ theme }) => theme.fontSizes.md}px;
  color: ${({ theme }) => theme.colors.muted};
  line-height: 1.7;
  margin: 0;
`;

Dark mode hook

import { useState, useCallback } from 'react';
import { ThemeProvider } from 'styled-components';
import { theme } from './theme';

export function ThemeRoot({ children }: { children: React.ReactNode }) {
  const [isDark, setIsDark] = useState(() => {
    if (typeof window === 'undefined') return false;
    const saved = localStorage.getItem('theme');
    return saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches);
  });

  const toggle = useCallback(() => {
    setIsDark(d => {
      const next = !d;
      localStorage.setItem('theme', next ? 'dark' : 'light');
      return next;
    });
  }, []);

  return (
    <ThemeProvider theme={isDark ? theme.dark : theme.light}>
      <GlobalStyles />
      {children}
    </ThemeProvider>
  );
}

Emotion

Emotion’s API is nearly identical to styled-components. The main difference is the import paths.

Install

npm install salt-theme-gen @emotion/react @emotion/styled

Theme typing

// src/theme/emotion.d.ts
import type { GeneratedThemeMode } from 'salt-theme-gen';
import '@emotion/react';

declare module '@emotion/react' {
  export interface Theme extends GeneratedThemeMode {}
}

Provider setup

import { ThemeProvider, Global, css } from '@emotion/react';
import { theme } from './theme';

function globalStyles(mode: typeof theme.light) {
  return css`
    body {
      margin: 0;
      background-color: ${mode.colors.background};
      color: ${mode.colors.text};
      font-size: ${mode.fontSizes.md}px;
      font-family: system-ui, -apple-system, sans-serif;
    }
  `;
}

export function ThemeRoot({ children, isDark }: { children: React.ReactNode; isDark: boolean }) {
  const mode = isDark ? theme.dark : theme.light;

  return (
    <ThemeProvider theme={mode}>
      <Global styles={globalStyles(mode)} />
      {children}
    </ThemeProvider>
  );
}

Emotion styled components

import styled from '@emotion/styled';

export const Button = styled.button`
  background: ${({ theme }) => theme.colors.primary};
  color: ${({ theme }) => theme.colors.onPrimary};
  border: none;
  border-radius: ${({ theme }) => theme.radius.md}px;
  padding: ${({ theme }) => theme.spacing.sm}px ${({ theme }) => theme.spacing.lg}px;
  font-weight: 600;
  cursor: pointer;

  &:hover { background: ${({ theme }) => theme.states.primary.hover}; }
`;

Emotion css prop

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

function Card({ title, body }: { title: string; body: string }) {
  const theme = useTheme();

  return (
    <div css={{
      background:   theme.surfaceElevation.card,
      border:       `1px solid ${theme.colors.border}`,
      borderRadius: theme.radius.lg,
      padding:      theme.spacing.xl,
    }}>
      <h3 css={{
        fontSize:     theme.fontSizes.lg,
        fontWeight:   700,
        color:        theme.colors.text,
        marginBottom: theme.spacing.sm,
      }}>
        {title}
      </h3>
      <p css={{
        fontSize:  theme.fontSizes.md,
        color:     theme.colors.muted,
        lineHeight: 1.7,
        margin: 0,
      }}>
        {body}
      </p>
    </div>
  );
}

vanilla-extract

vanilla-extract generates CSS at build time — zero runtime overhead. Token values are written into static CSS files during the build.

Install

npm install salt-theme-gen @vanilla-extract/css @vanilla-extract/vite-plugin

Create theme contract

// src/theme/contract.css.ts
import { createThemeContract } from '@vanilla-extract/css';

export const vars = createThemeContract({
  colors: {
    primary:    null,
    secondary:  null,
    background: null,
    surface:    null,
    text:       null,
    muted:      null,
    border:     null,
    danger:     null,
    success:    null,
    warning:    null,
    info:       null,
    onPrimary:  null,
    onDanger:   null,
    onSuccess:  null,
    onWarning:  null,
    onInfo:     null,
  },
  spacing: {
    xs: null, sm: null, md: null, lg: null, xl: null, xxl: null,
  },
  radius: {
    sm: null, md: null, lg: null, xl: null, pill: null,
  },
  fontSizes: {
    xs: null, sm: null, md: null, lg: null, xl: null, xxl: null, '3xl': null,
  },
});

Implement the contract for each mode

// src/theme/themes.css.ts
import { createTheme } from '@vanilla-extract/css';
import { generateTheme } from 'salt-theme-gen';
import { vars } from './contract.css';

const theme = generateTheme({ preset: 'ocean' });

function px(n: number) { return `${n}px`; }

export const lightTheme = createTheme(vars, {
  colors: {
    primary:    theme.light.colors.primary,
    secondary:  theme.light.colors.secondary,
    background: theme.light.colors.background,
    surface:    theme.light.colors.surface,
    text:       theme.light.colors.text,
    muted:      theme.light.colors.muted,
    border:     theme.light.colors.border,
    danger:     theme.light.colors.danger,
    success:    theme.light.colors.success,
    warning:    theme.light.colors.warning,
    info:       theme.light.colors.info,
    onPrimary:  theme.light.colors.onPrimary,
    onDanger:   theme.light.colors.onDanger,
    onSuccess:  theme.light.colors.onSuccess,
    onWarning:  theme.light.colors.onWarning,
    onInfo:     theme.light.colors.onInfo,
  },
  spacing: {
    xs: px(theme.light.spacing.xs), sm: px(theme.light.spacing.sm),
    md: px(theme.light.spacing.md), lg: px(theme.light.spacing.lg),
    xl: px(theme.light.spacing.xl), xxl: px(theme.light.spacing.xxl),
  },
  radius: {
    sm: px(theme.light.radius.sm),   md: px(theme.light.radius.md),
    lg: px(theme.light.radius.lg),   xl: px(theme.light.radius.xl),
    pill: px(theme.light.radius.pill),
  },
  fontSizes: {
    xs: px(theme.light.fontSizes.xs), sm: px(theme.light.fontSizes.sm),
    md: px(theme.light.fontSizes.md), lg: px(theme.light.fontSizes.lg),
    xl: px(theme.light.fontSizes.xl), xxl: px(theme.light.fontSizes.xxl),
    '3xl': px(theme.light.fontSizes['3xl']),
  },
});

export const darkTheme = createTheme(vars, {
  colors: {
    primary:    theme.dark.colors.primary,
    // ... same structure with theme.dark values
  },
  // ...
});

Use in component styles

// src/components/Button/Button.css.ts
import { style } from '@vanilla-extract/css';
import { vars } from '../../theme/contract.css';

export const button = style({
  background:   vars.colors.primary,
  color:        vars.colors.onPrimary,
  border:       'none',
  borderRadius: vars.radius.md,
  padding:      `${vars.spacing.sm} ${vars.spacing.lg}`,
  fontSize:     vars.fontSizes.md,
  fontWeight:   600,
  cursor:       'pointer',
});
// Button.tsx
import * as styles from './Button.css';

export function Button({ children }: { children: React.ReactNode }) {
  return <button className={styles.button}>{children}</button>;
}

Choosing between approaches

LibraryRuntime costDark modeType safetyBest for
styled-componentsMediumRe-renderExcellentReact apps with complex dynamic styles
EmotionLow-mediumRe-renderExcellentReact apps, Next.js, design systems
vanilla-extractZeroClass swapExcellentPerformance-critical apps, SSR-first

All three support GeneratedThemeMode as the theme type with zero additional glue code.


CSS variables vs CSS-in-JS — which to use?

If your project already uses styled-components or Emotion, the patterns above give you typed token access. If you are starting fresh, consider whether CSS-in-JS is necessary:

Use CSS variables whenUse CSS-in-JS when
You want zero runtime overheadYou have complex dynamic styles
You need SSR without hydrationYou already use styled-components/Emotion
Your team knows CSS wellYou want co-located component styles
You use multiple frameworksYou are React-only

Both approaches use the same generateTheme() call and the same token values. CSS-in-JS adds a runtime but gains co-location and full TypeScript at the interpolation level.

Mixing approaches: CSS variables and CSS-in-JS are not mutually exclusive. You can inject CSS variables globally (for base styles and non-component CSS) and use CSS-in-JS for component-level styles. Both read from the same generated token values — the source of truth stays generateTheme().

Live demo

Open this integration in StackBlitz — fully working, editable in your browser.

Open in StackBlitz →