DEV Community

Cover image for How to Structure Design Tokens for Light and Dark Mode
Hasan Sarwer
Hasan Sarwer

Posted on

How to Structure Design Tokens for Light and Dark Mode

Dark mode becomes messy when tokens are not structured from the beginning.

At first, it feels simple.

You define a few CSS variables:

:root {
  --background: #ffffff;
  --text: #111827;
  --primary: #2563eb;
}
Enter fullscreen mode Exit fullscreen mode

Then later you add dark mode:

[data-theme="dark"] {
  --background: #020617;
  --text: #f8fafc;
  --primary: #60a5fa;
}
Enter fullscreen mode Exit fullscreen mode

This works for a small project.

But as the UI grows, the theme becomes harder to manage.

You need tokens for cards, borders, muted text, buttons, hover states, danger states, success states, warning states, disabled states, focus rings, spacing, radius, and typography.

If everything lives in one flat color list, dark mode quickly becomes difficult to maintain.

The problem is not dark mode itself.

The problem is unstructured tokens.

Start with a theme object

A better approach is to structure the theme from the beginning.

Instead of thinking only in CSS variables, start with a theme object.

const theme = {
  light: {
    colors: {},
    states: {},
    spacing: {},
    radius: {},
    fontSizes: {},
    accessibility: {}
  },
  dark: {
    colors: {},
    states: {},
    spacing: {},
    radius: {},
    fontSizes: {},
    accessibility: {}
  }
};
Enter fullscreen mode Exit fullscreen mode

This gives the system clear groups.

Colors are not mixed with spacing.

Spacing is not mixed with radius.

Accessibility checks are not hidden inside component code.

Each part of the theme has a job.

Separate light and dark modes

Light and dark mode should use the same token names.

Only the values should change.

const theme = {
  light: {
    colors: {
      background: "#ffffff",
      foreground: "#111827",
      surface: "#f8fafc",
      surfaceElevated: "#ffffff",
      border: "#e5e7eb",
      muted: "#64748b",

      primary: "#2563eb",
      onPrimary: "#ffffff",

      danger: "#dc2626",
      onDanger: "#ffffff",

      warning: "#facc15",
      onWarning: "#111827",

      success: "#16a34a",
      onSuccess: "#ffffff"
    }
  },

  dark: {
    colors: {
      background: "#020617",
      foreground: "#f8fafc",
      surface: "#0f172a",
      surfaceElevated: "#1e293b",
      border: "#334155",
      muted: "#94a3b8",

      primary: "#60a5fa",
      onPrimary: "#0f172a",

      danger: "#f87171",
      onDanger: "#450a0a",

      warning: "#facc15",
      onWarning: "#111827",

      success: "#4ade80",
      onSuccess: "#052e16"
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

The important part is consistency.

Both modes have:

background
foreground
surface
surfaceElevated
border
muted
primary
onPrimary
danger
onDanger
warning
onWarning
success
onSuccess
Enter fullscreen mode Exit fullscreen mode

The component should not care whether the current mode is light or dark.

It should only use the semantic token.

Use semantic tokens, not raw color names

Avoid building components around names like this:

--blue-600
--gray-900
--yellow-400
Enter fullscreen mode Exit fullscreen mode

Those names describe color values.

They do not describe UI roles.

A button does not need to know that it is blue.

It needs to know that it is primary.

.button-primary {
  background: var(--color-primary);
  color: var(--color-on-primary);
}
Enter fullscreen mode Exit fullscreen mode

A warning alert does not need to know that it is yellow.

It needs to know that it is warning.

.alert-warning {
  background: var(--color-warning);
  color: var(--color-on-warning);
}
Enter fullscreen mode Exit fullscreen mode

This makes dark mode easier because the component contract stays stable.

Only the token values change.

Keep states separate

Interactive states should not be random values inside components.

They should be part of the token system.

const theme = {
  light: {
    states: {
      hover: "rgba(15, 23, 42, 0.06)",
      pressed: "rgba(15, 23, 42, 0.12)",
      focus: "#2563eb",
      disabledBackground: "#e5e7eb",
      disabledForeground: "#94a3b8"
    }
  },

  dark: {
    states: {
      hover: "rgba(248, 250, 252, 0.08)",
      pressed: "rgba(248, 250, 252, 0.14)",
      focus: "#60a5fa",
      disabledBackground: "#1e293b",
      disabledForeground: "#64748b"
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

This helps avoid a common problem:

one button has one hover color, another button has another hover color, and a third component invents a new disabled color.

When states are tokens, components become more consistent.

.button:hover {
  background: var(--state-hover);
}

.button:focus-visible {
  outline: 2px solid var(--state-focus);
}

.button:disabled {
  background: var(--state-disabled-background);
  color: var(--state-disabled-foreground);
}
Enter fullscreen mode Exit fullscreen mode

States should be designed, not guessed.

Keep spacing independent from color mode

Not every token needs separate light and dark values.

Spacing usually does not change between modes.

const spacing = {
  xs: "0.25rem",
  sm: "0.5rem",
  md: "1rem",
  lg: "1.5rem",
  xl: "2rem"
};
Enter fullscreen mode Exit fullscreen mode

You can keep spacing outside the light/dark mode object:

const theme = {
  spacing: {
    xs: "0.25rem",
    sm: "0.5rem",
    md: "1rem",
    lg: "1.5rem",
    xl: "2rem"
  },

  modes: {
    light: {},
    dark: {}
  }
};
Enter fullscreen mode Exit fullscreen mode

This keeps the structure clean.

Color changes by mode.

Spacing usually stays global.

Do the same for radius

Radius tokens should also be separate.

const radius = {
  sm: "0.25rem",
  md: "0.5rem",
  lg: "0.75rem",
  xl: "1rem",
  full: "9999px"
};
Enter fullscreen mode Exit fullscreen mode

Then components can use consistent shape values:

.card {
  border-radius: var(--radius-lg);
}

.button {
  border-radius: var(--radius-md);
}

.badge {
  border-radius: var(--radius-full);
}
Enter fullscreen mode Exit fullscreen mode

This avoids magic values like:

border-radius: 7px;
border-radius: 11px;
border-radius: 999px;
Enter fullscreen mode Exit fullscreen mode

A design system should make repetition intentional.

Keep font sizes structured

Typography also needs structure.

const fontSizes = {
  xs: "0.75rem",
  sm: "0.875rem",
  base: "1rem",
  lg: "1.125rem",
  xl: "1.25rem",
  "2xl": "1.5rem",
  "3xl": "1.875rem"
};
Enter fullscreen mode Exit fullscreen mode

Then UI text can follow a consistent scale:

.caption {
  font-size: var(--font-size-xs);
}

.body {
  font-size: var(--font-size-base);
}

.heading {
  font-size: var(--font-size-2xl);
}
Enter fullscreen mode Exit fullscreen mode

Font-size tokens usually do not need light and dark versions.

But they still belong in the same token system.

Add accessibility checks as part of the structure

Accessibility should not be an afterthought.

A theme system should know which foreground/background pairs are intended to work together.

For example:

const accessibility = {
  pairs: [
    ["foreground", "background"],
    ["muted", "background"],
    ["foreground", "surface"],
    ["primary", "onPrimary"],
    ["danger", "onDanger"],
    ["warning", "onWarning"],
    ["success", "onSuccess"]
  ]
};
Enter fullscreen mode Exit fullscreen mode

Or better, store the relationship directly with the color roles.

const theme = {
  light: {
    colors: {
      primary: "#2563eb",
      onPrimary: "#ffffff"
    },
    accessibility: {
      primary: {
        background: "primary",
        foreground: "onPrimary"
      }
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

This makes the purpose clear.

The system is not just storing colors.

It is storing readable relationships.

Generate CSS variables from the structure

Once the theme object is structured, CSS variables become an output.

:root {
  --color-background: #ffffff;
  --color-foreground: #111827;
  --color-surface: #f8fafc;
  --color-border: #e5e7eb;

  --color-primary: #2563eb;
  --color-on-primary: #ffffff;

  --space-md: 1rem;
  --radius-md: 0.5rem;
  --font-size-base: 1rem;
}

:root[data-theme="dark"] {
  --color-background: #020617;
  --color-foreground: #f8fafc;
  --color-surface: #0f172a;
  --color-border: #334155;

  --color-primary: #60a5fa;
  --color-on-primary: #0f172a;
}
Enter fullscreen mode Exit fullscreen mode

The CSS variable layer is the runtime layer.

The theme object is the source structure.

This makes it easier to export the same system to different places:

CSS variables
Tailwind config
React theme object
React Native theme object
DTCG tokens
Figma tokens
Enter fullscreen mode Exit fullscreen mode

The source stays organized.

The outputs can change.

Component code becomes simpler

When tokens are structured well, component CSS becomes boring.

That is a good thing.

.card {
  background: var(--color-surface);
  color: var(--color-foreground);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: var(--space-lg);
}

.button-primary {
  background: var(--color-primary);
  color: var(--color-on-primary);
  border-radius: var(--radius-md);
  padding: var(--space-sm) var(--space-md);
}

.text-muted {
  color: var(--color-muted);
}
Enter fullscreen mode Exit fullscreen mode

The component does not invent colors.

It uses the token contract.

A simple structure to start with

A practical theme structure can look like this:

const tokens = {
  modes: {
    light: {
      colors: {
        background: "#ffffff",
        foreground: "#111827",
        surface: "#f8fafc",
        border: "#e5e7eb",
        muted: "#64748b",

        primary: "#2563eb",
        onPrimary: "#ffffff",

        danger: "#dc2626",
        onDanger: "#ffffff",

        warning: "#facc15",
        onWarning: "#111827",

        success: "#16a34a",
        onSuccess: "#ffffff"
      },

      states: {
        hover: "rgba(15, 23, 42, 0.06)",
        pressed: "rgba(15, 23, 42, 0.12)",
        focus: "#2563eb",
        disabledBackground: "#e5e7eb",
        disabledForeground: "#94a3b8"
      }
    },

    dark: {
      colors: {
        background: "#020617",
        foreground: "#f8fafc",
        surface: "#0f172a",
        border: "#334155",
        muted: "#94a3b8",

        primary: "#60a5fa",
        onPrimary: "#0f172a",

        danger: "#f87171",
        onDanger: "#450a0a",

        warning: "#facc15",
        onWarning: "#111827",

        success: "#4ade80",
        onSuccess: "#052e16"
      },

      states: {
        hover: "rgba(248, 250, 252, 0.08)",
        pressed: "rgba(248, 250, 252, 0.14)",
        focus: "#60a5fa",
        disabledBackground: "#1e293b",
        disabledForeground: "#64748b"
      }
    }
  },

  spacing: {
    xs: "0.25rem",
    sm: "0.5rem",
    md: "1rem",
    lg: "1.5rem",
    xl: "2rem"
  },

  radius: {
    sm: "0.25rem",
    md: "0.5rem",
    lg: "0.75rem",
    xl: "1rem",
    full: "9999px"
  },

  fontSizes: {
    xs: "0.75rem",
    sm: "0.875rem",
    base: "1rem",
    lg: "1.125rem",
    xl: "1.25rem",
    "2xl": "1.5rem"
  },

  accessibility: {
    contrastPairs: [
      ["foreground", "background"],
      ["muted", "background"],
      ["foreground", "surface"],
      ["onPrimary", "primary"],
      ["onDanger", "danger"],
      ["onWarning", "warning"],
      ["onSuccess", "success"]
    ]
  }
};
Enter fullscreen mode Exit fullscreen mode

This is not the only possible structure.

But it gives the system a clear shape.

Final thought

Dark mode is not hard because dark colors are hard.

Dark mode becomes hard when the token system has no structure.

A good theme structure separates:

colors
states
spacing
radius
font sizes
accessibility checks
Enter fullscreen mode Exit fullscreen mode

It also keeps light and dark mode using the same semantic names.

Components should not need to know whether the current theme is light or dark.

They should only know the role they need:

background: var(--color-primary);
color: var(--color-on-primary);
Enter fullscreen mode Exit fullscreen mode

The theme should define the values.

The component should use the contract.

That is how dark mode stays maintainable as the UI grows.

Top comments (0)