Using CSS Custom Properties As Design Tokens (Without A Design System)

How I use plain CSS custom properties as a design token system for real projects. Spacing, color, and motion tokens. No Figma. No toolchain. Just code.
Using CSS Custom Properties As Design Tokens (Without A Design System)
Photo by Tianyi Ma / Unsplash

Design tokens without the religion

I like the idea of design tokens. I do not like the religion around them.

Most articles go straight into Figma plugins, token sync tools, JSON pipelines, and a 40-slide deck about design systems. I just want consistent spacing, color, and motion in a codebase that ships.

So I use CSS custom properties as my token system. No Figma. No special tooling. No JSON. Just :root and a set of naming rules that survive contact with real projects.

This is how I actually run it on production work. Not a theoretical setup. Messy client requirements included.

The constraints I design for

Before the code, some constraints I keep in mind.

  • I assume designers are sending me screenshots or static Figma frames, not a token spec.
  • I assume I will be the one maintaining this six months later, slightly annoyed at December-me.
  • I assume there will be a dark mode request right after launch.
  • I assume the PM will say "can we tighten everything up by 2px" at some point.

So the system has to be small, boring, and editable in one place. That is the bar.

The core idea: one boring :root

I keep almost everything in a single :root block. If the stack allows, this lives in globals.css or a base stylesheet that loads first.

:root {
  /* spacing */
  --space-0: 0;
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-12: 3rem;

  /* color */
  --color-bg: #05060a;
  --color-surface: #0b0d12;
  --color-border-subtle: #202431;
  --color-text: #f9fafb;
  --color-text-muted: #9ca3af;
  --color-accent: #38bdf8;
  --color-accent-soft: rgba(56, 189, 248, 0.12);
  --color-danger: #f97373;

  /* motion */
  --ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-snappy: cubic-bezier(0.16, 1, 0.3, 1);

  --duration-fast: 120ms;
  --duration-normal: 200ms;
  --duration-slow: 320ms;
}

This is the entire "design system" for a surprising number of projects. It covers 80 percent of real UI decisions: margins, paddings, backgrounds, text, borders, and motion curves.

Why I use scale-like names for spacing

Spacing is where I see the most chaos in real codebases. Margins with hardcoded 3px, 18px, 22px, and a random 0.9rem that no one remembers adding.

I use a fake scale: --space-0, --space-1, --space-2, --space-3, --space-4, --space-6, --space-8, --space-12. It is not a perfect mathematical ratio. I do not care. It just has to be consistent enough that I can see patterns.

Real usage looks like this:

.stack {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.section {
  padding-block: var(--space-12);
  padding-inline: var(--space-4);
}

.card {
  padding: var(--space-4);
  border-radius: 0.75rem;
}

.card + .card {
  margin-top: var(--space-3);
}

I like gap with tokens. It lets me keep most vertical rhythm in one dimension: stacks and grids. If a PM asks to "open it up a bit", I tweak --space-4 and sometimes --space-3, commit, done.

Two rules I actually follow when I am tired:

  • If you use a raw pixel in a margin twice, it becomes a token.
  • If you add a new token, you remove one you barely use.

The second rule keeps the scale small. I do not want a 20-step system that matches some fancy design spec. I want six to eight that I can remember.

Colors without chasing perfect naming

Color naming can get religious fast. I do not ship --color-primary-50 up to --color-primary-900 unless the project is huge and actually needs it.

Most projects I work on settle into something like this.

:root {
  --color-bg: #05060a;
  --color-surface: #0b0d12;
  --color-surface-elevated: #111827;

  --color-border-subtle: #202431;
  --color-border-strong: #374151;

  --color-text: #f9fafb;
  --color-text-muted: #9ca3af;
  --color-text-soft: #6b7280;

  --color-accent: #38bdf8;
  --color-accent-soft: rgba(56, 189, 248, 0.12);
  --color-accent-strong: #0ea5e9;

  --color-danger: #f97373;
  --color-danger-soft: rgba(249, 115, 115, 0.12);
}

I lean on semantics over exact palette indices. --color-surface-elevated and --color-border-subtle are more useful to me than --color-gray-800. I want the name to tell me where it lives in the UI, not its hex number lineage.

Actual usage:

body {
  background: var(--color-bg);
  color: var(--color-text);
}

.card {
  background: var(--color-surface);
  border: 1px solid var(--color-border-subtle);
}

.card--highlighted {
  border-color: var(--color-accent);
  box-shadow: 0 0 0 1px var(--color-accent-soft);
}

.text-muted {
  color: var(--color-text-muted);
}

.button-primary {
  background: var(--color-accent);
  color: var(--color-bg);
}

.button-primary:hover {
  background: var(--color-accent-strong);
}

When you stick to these semantic tokens, swapping a brand color, tightening contrast, or adding a high contrast mode costs a lot less mental energy.

Motion is where most projects go freestyle. Buttons use 150ms ease-in-out, dropdowns use 300ms with a different curve, modals ease in like molasses.

I standardise motion just enough so everything feels related. Not overthought. Just aligned.

:root {
  --ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-snappy: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-soft: cubic-bezier(0.4, 0, 0.2, 1);

  --duration-fast: 120ms;
  --duration-normal: 200ms;
  --duration-slow: 320ms;
}

.button-primary {
  transition:
    background-color var(--duration-normal) var(--ease-standard),
    transform var(--duration-fast) var(--ease-snappy),
    box-shadow var(--duration-normal) var(--ease-standard);
}

.button-primary:hover {
  transform: translateY(-1px);
  box-shadow: 0 12px 24px rgba(15, 23, 42, 0.35);
}

.modal {
  transition:
    opacity var(--duration-normal) var(--ease-soft),
    transform var(--duration-normal) var(--ease-soft);
}

This is enough to keep the experience cohesive. When I tweak --duration-normal late in the project, I see the whole UI adjust slightly and suddenly it feels more responsive. One commit. No hunting through files for stray 200ms values.

Layering themes with CSS variables, not tools

The other reason I like custom properties as tokens is that theming is just CSS overrides. No JS theme provider needed for a lot of cases.

Base theme in :root.

:root {
  --color-bg: #05060a;
  --color-surface: #0b0d12;
  --color-text: #f9fafb;
  --color-text-muted: #9ca3af;
  --color-accent: #38bdf8;
}

Then add a light theme as a modifier class.

.theme-light {
  --color-bg: #f9fafb;
  --color-surface: #ffffff;
  --color-text: #020617;
  --color-text-muted: #6b7280;
  --color-accent: #0ea5e9;
}

In the markup, I just set the class on <html> or <body>.

<body class="theme-light">
  ...
</body>

You can switch this from JS by toggling the class. That is enough for most marketing sites and admin dashboards I build. If I need user preferences, I sync with prefers-color-scheme and store it.

@media (prefers-color-scheme: light) {
  :root {
    color-scheme: light;
  }
}

No token JSON. No third-party theming engine. Just overrides.

Component-level tokens when the global ones are not enough

Sometimes a component wants its own tiny language. A hero section with a gradient. A pricing card stack. A special CTA block. I use local tokens for those, but I always build them on top of the global ones.

.hero {
  --hero-gradient-start: var(--color-accent);
  --hero-gradient-end: #a855f7;
  --hero-padding-y: var(--space-12);
  --hero-padding-x: var(--space-4);

  padding: var(--hero-padding-y) var(--hero-padding-x);
  background: radial-gradient(
    circle at top left,
    var(--hero-gradient-start),
    var(--hero-gradient-end)
  );
}

The key is that component tokens usually depend on global tokens. That lets me refactor globally later without untangling a hundred magic values.

If a component token does not reference globals at all, I flag it. It might be a real one-off. Or it might be exposing a missing global concept.

How I retrofit tokens into an existing messy project

Most of my work does not start from zero. I join halfway, stare at a tangle of CSS, and then slowly introduce tokens without stopping the project.

The process I actually use looks like this.

  1. Search for duplicate pixel values: 8, 12, 16, 24, 32, 40.
  2. Collect the top 5 to 8 values that keep repeating.
  3. Create --space-x tokens that roughly match them.
  4. Start replacing values per component, not globally.

Same for colors. I search for the top 3 gray values, the primary accent, and any repeated background. Those become tokens. I do not try to tokenise every single shade from day one.

For motion, I usually start by defining one --duration-normal that matches the most common duration, then slowly replace. The moment I see three different ease-* curves in a file, they turn into variables.

The main trick is low ambition. I am not "implementing a design system" on a legacy codebase. I am just centralising the stuff I already repeat so I can change it later without hating myself.

Where I stop on purpose

There is a line where this stops being helpful and starts being ceremony. I try not to cross it.

  • I do not tokenize every single border radius. I pick 2 or 3 and hardcode the rest.
  • I do not build typography tokens for tiny projects. A couple of utility classes is fine.
  • I do not mirror the Figma token structure 1:1, even when it exists. Code has different needs.

If I spend more time naming tokens than writing components, I cut back. I treat tokens like database indexes. You add them where they reduce pain, not everywhere “just in case”.

How this actually feels in daily work

On a typical day, the benefit is not dramatic. It just feels slightly less annoying to tweak things.

A client wants the whole layout to "breathe" more. I bump --space-4 and --space-6 by 0.125rem, check a few key pages, done.

Accessibility review says body text contrast is low. I adjust --color-text and --color-bg together, no hunting for half a dozen text colors.

A marketing stakeholder wants the site to "feel snappier". I shorten --duration-normal to 160ms and switch a couple of components to --ease-snappy. That is it.

The system is simple enough that I can keep most of it in my head, but structured enough that I am not scared to refactor at the end of a project.

No Figma, no tooling, still a system

This is not a "proper" design system by enterprise standards. There is no token pipeline, no auto-sync with Figma, no generated docs site.

What I have instead is a very small, very practical layer of CSS variables that behave like design tokens without needing special treatment. They help me ship faster, and future-me has fewer reasons to swear at past-me.

If your project already has Tailwind or a heavy component library, fine. But if you are building a custom UI, or stitching together a bunch of bespoke layouts, plain CSS custom properties as tokens are more than enough. You can start with ten lines in :root and grow from there.

No design system approvals. No extra toolchain. Just CSS.

Subscribe to my newsletter

Subscribe to my newsletter to get the latest updates and news

Member discussion