I'm going to say something that might ruffle some feathers: I stopped using Tailwind CSS on new projects in late 2025, and I haven't looked back. Not because Tailwind is bad -- it's genuinely excellent at what it does. But because vanilla CSS has gotten so good that the tradeoffs no longer make sense for the kinds of projects we build.

Design tokens are at the heart of this shift. The idea of storing your design decisions as reusable, platform-agnostic variables isn't new. But the tooling and browser support around CSS custom properties in 2026 have matured to the point where you can build a full design token system with zero build steps, zero dependencies, and zero utility class memorization. Let me show you how.

Table of Contents

Vanilla CSS Design Tokens Without Tailwind in 2026

What Design Tokens Actually Are

Design tokens are the atomic values that define your visual language. Colors, spacing, typography, shadows, border radii, animation durations -- anything that represents a design decision. The term was coined by Salesforce's design team back in 2014, but the concept has evolved significantly.

Here's the key insight most people miss: design tokens aren't just variables. They're a contract between design and engineering. When your designer says "use the primary action color," that maps to a token, not a hex value. When they say "medium spacing," that's a token too.

The W3C Design Tokens Community Group published the Design Tokens Format Module specification, and while it's still evolving, the core ideas have solidified. Tokens exist in layers:

  1. Primitive tokens (raw values like #1a73e8 or 16px)
  2. Semantic tokens (purpose-driven aliases like color-action-primary)
  3. Component tokens (scoped to specific UI elements like button-background)

This three-tier architecture is what makes large-scale systems manageable. CSS custom properties map to it perfectly.

Why Vanilla CSS Won Me Over

Let me be honest about my journey. I used Tailwind happily for years. Built production sites with it. Recommended it to clients. But several things changed:

CSS has caught up. Nesting, :has(), container queries, @layer, @scope, color-mix(), oklch(), relative color syntax -- these aren't experimental features anymore. They're baseline in every major browser in 2026. The gap that utility frameworks filled? It's shrunk dramatically.

Bundle size matters more than ever. With Core Web Vitals directly impacting search rankings, every kilobyte counts. A well-structured vanilla CSS token system weighs almost nothing. Tailwind's JIT compiler is clever, but it still ships CSS for every utility you use.

Maintenance cost is real. I've inherited Tailwind projects where components have 30+ utility classes strung together. Reading className="flex items-center justify-between px-4 py-2 bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 border border-gray-200 dark:border-gray-700" is not fun. With design tokens and a thin layer of CSS, the intent is clearer.

Design-engineering handoff improved. When tokens in CSS match tokens in Figma 1:1, conversations become precise. No more "which Tailwind class maps to this Figma variable?"

This isn't a holy war. If Tailwind works for your team, keep using it. But if you're starting something new and want maximum control with minimum abstraction, vanilla CSS tokens are worth serious consideration.

Setting Up Your Token Architecture

Let's build this from scratch. I'll use the file structure we've refined across multiple headless CMS projects at Social Animal, but adapt it to your needs.

The core principle: tokens flow downward through specificity layers.

tokens/
├── primitives.css       /* Raw values */
├── semantic.css         /* Purpose-driven aliases */
├── components/
│   ├── button.css
│   ├── card.css
│   └── input.css
├── themes/
│   ├── light.css
│   └── dark.css
└── index.css            /* Import orchestration */

Your index.css uses @layer to establish a clear cascade:

@layer primitives, semantic, themes, components;

@import './primitives.css' layer(primitives);
@import './semantic.css' layer(semantic);
@import './themes/light.css' layer(themes);
@import './components/button.css' layer(components);
@import './components/card.css' layer(components);
@import './components/input.css' layer(components);

This is important. @layer gives you explicit control over the cascade without fighting specificity. Primitives are overridden by semantic tokens, which are overridden by themes, which are overridden by components. Clean hierarchy.

Vanilla CSS Design Tokens Without Tailwind in 2026 - architecture

Primitive Tokens: Your Raw Values

Primitives are your palette. They're not meant to be used directly in components -- think of them as the paint on your shelf before you decide what to paint.

/* primitives.css */
:root {
  /* Colors using OKLCH for perceptually uniform palettes */
  --color-blue-50: oklch(0.97 0.01 250);
  --color-blue-100: oklch(0.93 0.03 250);
  --color-blue-200: oklch(0.87 0.06 250);
  --color-blue-300: oklch(0.78 0.10 250);
  --color-blue-400: oklch(0.68 0.15 250);
  --color-blue-500: oklch(0.58 0.19 250);
  --color-blue-600: oklch(0.50 0.19 250);
  --color-blue-700: oklch(0.42 0.17 250);
  --color-blue-800: oklch(0.35 0.14 250);
  --color-blue-900: oklch(0.27 0.10 250);

  /* Spacing scale (modular, based on 4px grid) */
  --space-1: 0.25rem;   /* 4px */
  --space-2: 0.5rem;    /* 8px */
  --space-3: 0.75rem;   /* 12px */
  --space-4: 1rem;      /* 16px */
  --space-5: 1.25rem;   /* 20px */
  --space-6: 1.5rem;    /* 24px */
  --space-8: 2rem;      /* 32px */
  --space-10: 2.5rem;   /* 40px */
  --space-12: 3rem;     /* 48px */
  --space-16: 4rem;     /* 64px */
  --space-20: 5rem;     /* 80px */
  --space-24: 6rem;     /* 96px */

  /* Typography scale */
  --font-size-xs: 0.75rem;
  --font-size-sm: 0.875rem;
  --font-size-base: 1rem;
  --font-size-md: 1.125rem;
  --font-size-lg: 1.25rem;
  --font-size-xl: 1.5rem;
  --font-size-2xl: 1.875rem;
  --font-size-3xl: 2.25rem;
  --font-size-4xl: 3rem;

  /* Font weights */
  --font-weight-normal: 400;
  --font-weight-medium: 500;
  --font-weight-semibold: 600;
  --font-weight-bold: 700;

  /* Border radius */
  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.75rem;
  --radius-xl: 1rem;
  --radius-full: 9999px;

  /* Shadows */
  --shadow-sm: 0 1px 2px oklch(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px oklch(0 0 0 / 0.07);
  --shadow-lg: 0 10px 15px oklch(0 0 0 / 0.1);
  --shadow-xl: 0 20px 25px oklch(0 0 0 / 0.1);

  /* Durations */
  --duration-fast: 100ms;
  --duration-normal: 200ms;
  --duration-slow: 300ms;
  --duration-slower: 500ms;
}

I'm using OKLCH here because it's a genuinely better color space. Unlike HSL, lightness in OKLCH is perceptually uniform -- a lightness of 0.5 actually looks like middle gray regardless of hue. Your color scales look consistent across the spectrum. All major browsers support it now.

Semantic Tokens: Where the Magic Happens

Semantic tokens reference primitives and give them meaning. This is the layer that makes theming possible and makes your CSS self-documenting.

/* semantic.css */
:root {
  /* Surface colors */
  --color-surface-primary: var(--color-white, #fff);
  --color-surface-secondary: var(--color-gray-50);
  --color-surface-tertiary: var(--color-gray-100);
  --color-surface-inverse: var(--color-gray-900);

  /* Text colors */
  --color-text-primary: var(--color-gray-900);
  --color-text-secondary: var(--color-gray-600);
  --color-text-tertiary: var(--color-gray-400);
  --color-text-inverse: var(--color-white, #fff);
  --color-text-link: var(--color-blue-600);
  --color-text-link-hover: var(--color-blue-700);

  /* Action colors */
  --color-action-primary: var(--color-blue-600);
  --color-action-primary-hover: var(--color-blue-700);
  --color-action-primary-active: var(--color-blue-800);

  /* Feedback colors */
  --color-feedback-success: var(--color-green-600);
  --color-feedback-warning: var(--color-amber-500);
  --color-feedback-error: var(--color-red-600);
  --color-feedback-info: var(--color-blue-500);

  /* Border colors */
  --color-border-primary: var(--color-gray-200);
  --color-border-secondary: var(--color-gray-100);
  --color-border-focus: var(--color-blue-500);

  /* Semantic spacing */
  --space-inline-xs: var(--space-1);
  --space-inline-sm: var(--space-2);
  --space-inline-md: var(--space-4);
  --space-inline-lg: var(--space-6);
  --space-stack-xs: var(--space-1);
  --space-stack-sm: var(--space-2);
  --space-stack-md: var(--space-4);
  --space-stack-lg: var(--space-8);
  --space-stack-xl: var(--space-12);
}

Notice how nothing here is a hard-coded value. Everything references primitives. If you want to rebrand tomorrow, you change primitives. If you want to tweak how "primary action" feels, you change semantic tokens. The component code never needs to know.

Component Tokens: The Final Layer

Component tokens scope design decisions to specific UI elements. This is optional for small projects but essential for anything with a design system.

/* components/button.css */
.button {
  --_bg: var(--color-action-primary);
  --_bg-hover: var(--color-action-primary-hover);
  --_bg-active: var(--color-action-primary-active);
  --_text: var(--color-text-inverse);
  --_radius: var(--radius-md);
  --_padding-block: var(--space-2);
  --_padding-inline: var(--space-4);
  --_font-size: var(--font-size-sm);
  --_font-weight: var(--font-weight-semibold);
  --_shadow: var(--shadow-sm);
  --_transition: var(--duration-normal);

  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--_padding-block) var(--_padding-inline);
  background: var(--_bg);
  color: var(--_text);
  font-size: var(--_font-size);
  font-weight: var(--_font-weight);
  border-radius: var(--_radius);
  box-shadow: var(--_shadow);
  transition: background var(--_transition), box-shadow var(--_transition);
  border: none;
  cursor: pointer;

  &:hover {
    background: var(--_bg-hover);
    box-shadow: var(--shadow-md);
  }

  &:active {
    background: var(--_bg-active);
  }

  &.--secondary {
    --_bg: transparent;
    --_text: var(--color-action-primary);
    --_shadow: none;
    border: 1px solid var(--color-border-primary);

    &:hover {
      --_bg: var(--color-surface-secondary);
    }
  }

  &.--ghost {
    --_bg: transparent;
    --_text: var(--color-text-primary);
    --_shadow: none;

    &:hover {
      --_bg: var(--color-surface-secondary);
    }
  }
}

The --_ prefix convention (with underscore) signals "private" component tokens. It's not enforced by the browser, but it's a clear signal to other developers: these tokens are internal to this component.

Also -- look at how CSS nesting works here. No preprocessor needed. This is native CSS in 2026.

Dark Mode and Theming Without a Framework

Here's where the token architecture really pays off. Dark mode becomes a matter of reassigning semantic tokens.

/* themes/dark.css */
@media (prefers-color-scheme: dark) {
  :root {
    --color-surface-primary: var(--color-gray-900);
    --color-surface-secondary: var(--color-gray-800);
    --color-surface-tertiary: var(--color-gray-700);
    --color-surface-inverse: var(--color-white, #fff);

    --color-text-primary: var(--color-gray-50);
    --color-text-secondary: var(--color-gray-300);
    --color-text-tertiary: var(--color-gray-500);
    --color-text-inverse: var(--color-gray-900);
    --color-text-link: var(--color-blue-400);
    --color-text-link-hover: var(--color-blue-300);

    --color-border-primary: var(--color-gray-700);
    --color-border-secondary: var(--color-gray-800);

    --shadow-sm: 0 1px 2px oklch(0 0 0 / 0.2);
    --shadow-md: 0 4px 6px oklch(0 0 0 / 0.3);
    --shadow-lg: 0 10px 15px oklch(0 0 0 / 0.4);
  }
}

/* Manual toggle support */
[data-theme="dark"] {
  --color-surface-primary: var(--color-gray-900);
  /* ... same overrides ... */
}

Your components don't change at all. Zero dark mode classes. Zero conditional logic. The tokens handle everything.

Want a brand theme for a white-label client? Same pattern:

[data-theme="client-acme"] {
  --color-blue-500: oklch(0.55 0.20 280); /* Their purple instead of blue */
  --color-action-primary: var(--color-blue-500);
  --radius-md: 1rem; /* They like rounder corners */
}

We use this approach extensively in our Next.js development projects where multi-tenant theming is a common requirement.

Responsive Tokens With Container Queries

This is something you genuinely can't do with Tailwind (at least not elegantly). Container queries let you change token values based on the container size, not just the viewport.

.card-grid {
  container-type: inline-size;
  container-name: card-grid;
}

@container card-grid (max-width: 500px) {
  .card {
    --_padding: var(--space-3);
    --_title-size: var(--font-size-base);
    --_gap: var(--space-2);
  }
}

@container card-grid (min-width: 501px) {
  .card {
    --_padding: var(--space-6);
    --_title-size: var(--font-size-lg);
    --_gap: var(--space-4);
  }
}

Components that respond to their own context rather than the viewport. This is particularly powerful when building component libraries for Astro sites where components get reused across wildly different layouts.

Syncing Design Tokens With Figma

Figma Variables (launched in 2023, significantly expanded since) align directly with our three-tier token model. Here's the workflow:

  1. Define tokens in Figma using their Variables panel (primitives → semantic → component)
  2. Export tokens using the Tokens Studio plugin or Figma's REST API
  3. Transform to CSS using Style Dictionary or the newer Cobalt UI tools
  4. Commit to your repo as your tokens/ directory

A Style Dictionary config for CSS output looks like:

{
  "source": ["tokens/**/*.json"],
  "platforms": {
    "css": {
      "transformGroup": "css",
      "buildPath": "src/styles/tokens/",
      "files": [
        {
          "destination": "primitives.css",
          "format": "css/variables",
          "filter": { "filePath": "tokens/primitives" }
        },
        {
          "destination": "semantic.css",
          "format": "css/variables",
          "filter": { "filePath": "tokens/semantic" }
        }
      ]
    }
  }
}

The result is a CI pipeline where design changes in Figma flow into your CSS tokens automatically. No manual translation, no drift.

Performance Comparison: Vanilla CSS vs Tailwind

I ran benchmarks on a real marketing site we rebuilt -- same design, two implementations. Here are the numbers:

Metric Tailwind v4 Vanilla CSS Tokens Difference
Total CSS size (gzipped) 14.2 KB 6.8 KB -52%
First Contentful Paint 1.2s 1.0s -17%
Largest Contentful Paint 2.1s 1.8s -14%
CSS parse time 3.2ms 1.4ms -56%
HTML size (gzipped) 28.4 KB 22.1 KB -22%
Build time 1.8s 0.4s* -78%

*Vanilla CSS with just @import bundling via Lightning CSS.

The HTML size difference is notable -- Tailwind's utility classes add significant weight to your markup. On a page with 200+ elements, those class strings add up.

That said, Tailwind v4 (released in early 2025) made big improvements with its Oxide engine. The gap has narrowed. But vanilla CSS with zero build dependencies is hard to beat on raw performance.

Real-World Token File Structure

Here's the actual structure from a recent project -- a headless commerce site built with Next.js and Sanity:

src/styles/
├── tokens/
│   ├── primitives/
│   │   ├── colors.css
│   │   ├── spacing.css
│   │   ├── typography.css
│   │   ├── shadows.css
│   │   ├── borders.css
│   │   └── motion.css
│   ├── semantic/
│   │   ├── colors.css
│   │   ├── spacing.css
│   │   └── typography.css
│   ├── themes/
│   │   ├── light.css
│   │   ├── dark.css
│   │   └── high-contrast.css
│   └── index.css
├── components/
│   ├── button.css
│   ├── card.css
│   ├── input.css
│   ├── modal.css
│   ├── navigation.css
│   └── typography.css
├── layouts/
│   ├── grid.css
│   └── container.css
├── utilities/
│   └── helpers.css          /* A few utility classes we actually need */
├── reset.css
└── main.css

The utilities/helpers.css file is interesting -- I still write a handful of utility classes for genuinely reusable patterns:

/* utilities/helpers.css */
.visually-hidden {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}

.flow > * + * {
  margin-block-start: var(--flow-space, var(--space-stack-md));
}

.cluster {
  display: flex;
  flex-wrap: wrap;
  gap: var(--cluster-gap, var(--space-inline-md));
  align-items: center;
}

Notice how even the utilities reference tokens. Everything stays connected.

Tooling That Makes This Practical

You don't need much, but a few tools make the DX significantly better:

Tool Purpose Notes
Lightning CSS Bundling, minification, vendor prefixing Replaces PostCSS for most use cases. Incredibly fast.
Style Dictionary 4 Token format transformation Figma → CSS, iOS, Android from a single source
Cobalt UI W3C DTCG token tooling Newer alternative to Style Dictionary, spec-aligned
Tokens Studio Figma plugin for managing tokens Best-in-class Figma integration
CSS Utility Kit (VS Code) Autocomplete for custom properties Shows token values on hover
Open Props Pre-made CSS custom property library Great starting point if you don't want to build from scratch

Lightning CSS deserves a special callout. Written in Rust, it handles @import inlining, nesting compilation for older browsers, and minification in a single pass. It's what we use in most of our headless CMS builds now.

lightningcss --bundle --minify src/styles/main.css -o dist/styles.css

That's it. No PostCSS config. No plugin chain. One command.

FAQ

Can I use design tokens without a build step?

Absolutely. If you're targeting modern browsers (and in 2026, you should be), native CSS custom properties work without any compilation. The only thing you lose is @import bundling into a single file -- browsers handle multiple CSS file imports just fine, though you'll want to bundle for production performance. Lightning CSS or even a simple cat command can handle that.

How do vanilla CSS design tokens compare to Tailwind v4?

Tailwind v4 actually moved toward CSS custom properties internally, which validates the approach. The difference is in developer experience: Tailwind gives you utility classes to apply in HTML, while vanilla tokens give you design variables to use in your own CSS. Vanilla tokens produce smaller CSS and HTML output, offer more flexibility for theming, and don't require learning a class naming system. Tailwind offers faster prototyping and stronger consistency enforcement for larger teams.

What about TypeScript type safety for design tokens?

If you're using CSS Modules or CSS-in-JS, you can generate TypeScript types from your token files. Style Dictionary 4 and Cobalt UI both support TypeScript output. For pure CSS, VS Code extensions like CSS Variable Autocomplete give you editor-level safety with autocomplete and validation.

Should I use Open Props instead of building my own tokens?

Open Props is an excellent starting point, especially for prototypes or smaller projects. It provides a well-designed set of CSS custom properties out of the box. For production design systems, though, you'll likely want to define your own primitives that match your brand. You can use Open Props as a reference and cherry-pick its approach to things like easing functions or shadow scales.

How do I handle design tokens in a monorepo with multiple apps?

Create a shared tokens package that exports your CSS files. Each app imports the token package and can add its own theme overrides. This works beautifully with workspaces in pnpm or npm. We've done this for multi-brand e-commerce clients where each storefront shares the same primitive tokens but has unique semantic mappings.

Is vanilla CSS slower to develop with than Tailwind?

Initially, yes -- you're setting up infrastructure that Tailwind gives you for free. But after the first week or two, velocity is comparable or faster. You're not context-switching between HTML and a utility class reference. You're writing CSS that reads like English. And when you need to change something, you change it in one place instead of searching for every instance of bg-blue-500.

How do I convince my team to drop Tailwind?

Don't frame it as dropping Tailwind -- frame it as adopting design tokens. Start by introducing a token layer alongside Tailwind: define your custom properties, reference them in a tailwind.config theme. Over time, you may find that the token system does most of the work and Tailwind becomes optional. Let the team arrive at that conclusion organically.

What about component libraries like Shadcn/UI that rely on Tailwind?

Shadcn/UI is fantastic, but its Tailwind dependency is implementation detail, not fundamental architecture. The component patterns and accessibility logic are what matter. Several community forks have emerged that use vanilla CSS tokens instead of Tailwind classes. You can also gradually migrate Shadcn/UI components to use your token system -- the underlying Radix UI primitives don't care how you style them.

Can I use this approach with Astro or Next.js?

Absolutely. Both frameworks handle vanilla CSS natively with zero configuration. Astro's scoped styles work perfectly with CSS custom properties since tokens defined at :root naturally cascade into scoped component styles. Next.js supports CSS Modules alongside global token files. We use this exact setup in our Next.js projects and Astro builds regularly. If you're exploring this for a new project, reach out to us -- we're happy to share what we've learned.