Building a Design System

Semantic design tokens, a small @apply-based component library, and where to draw the line between the two.

From "a pile of utilities" to a design system

Using Tailwind well on a small page is mostly about knowing the utility names. Using it well on a product — dozens of screens, multiple contributors, a design that has to stay consistent for years — is a different problem: it's about making sure everyone reaches for the same handful of values instead of independently picking new ones every time. That's what a design system actually is in practice: not a component library on its own, but an agreed-upon, limited set of values (colors, spacing, radii, type sizes) that every component is built from, so the whole product looks like it was designed by one person even when it wasn't.

Design tokens: naming things by role, not by value

The Customization page in this track showed extending theme.colors with a literal brand palette (brand.500, brand.600, etc.). That's a good first step, but it still names colors by what they are, not what they're for. A more durable design system adds a semantic layer on top — names based on the color's role in the UI, which is what actually stays stable when a rebrand changes the underlying palette:

Javascript
// tailwind.config.js
module.exports = {
    content: ['./resources/**/*.blade.php'],
    theme: {
        extend: {
            colors: {
                primary: {
                    DEFAULT: '#4f46e5',
                    hover: '#4338ca',
                    foreground: '#ffffff',
                },
                surface: {
                    DEFAULT: '#ffffff',
                    subtle: '#f9fafb',
                },
                danger: {
                    DEFAULT: '#dc2626',
                    hover: '#b91c1c',
                },
            },
            borderRadius: {
                DEFAULT: '0.5rem',
            },
        },
    },
};
HTML
<button class="bg-primary hover:bg-primary-hover text-primary-foreground">Save</button>
<div class="bg-surface-subtle rounded p-4">Card content</div>

The difference matters the moment the brand color changes: with semantic tokens, bg-primary is redefined once in tailwind.config.js and every button, link, and focus ring built from it updates automatically. Without it — if components had hard-coded bg-indigo-600 directly — a rebrand means hunting down and replacing that literal value across the entire codebase.

A small component library with @apply

Once a handful of components (buttons, badges, form inputs) are genuinely repeated across dozens of screens with the same long utility string every time, folding each into a single class with @apply turns "copy this exact combination of 8 utilities correctly, every time" into "add one class name":

Css
/* resources/css/components.css */
@layer components {
    .btn {
        @apply inline-flex items-center justify-center rounded px-4 py-2 font-semibold transition;
    }

    .btn-primary {
        @apply btn bg-primary text-primary-foreground hover:bg-primary-hover;
    }

    .btn-danger {
        @apply btn bg-danger text-white hover:bg-danger-hover;
    }

    .btn-outline {
        @apply btn border border-primary text-primary hover:bg-primary hover:text-primary-foreground;
    }

    .badge {
        @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium;
    }

    .badge-success {
        @apply badge bg-green-100 text-green-800;
    }
}
HTML
<button class="btn-primary">Save changes</button>
<button class="btn-danger">Delete</button>
<button class="btn-outline">Cancel</button>
<span class="badge-success">Active</span>

Notice every @apply line still references the semantic tokens (bg-primary, not bg-indigo-600) — this is the detail that makes the two layers reinforce each other instead of working against each other. The token layer is the single source of truth for values; the component layer is a thin, purely organizational layer naming combinations of those values. Change a token, and every component built from it (both the @apply-based ones here and any inline utility usage elsewhere) updates together.

Where to draw the line

Design tokens (theme.extend) @apply components
What it captures Individual values — a color, a spacing step, a radius A fixed combination of several utilities, reused as-is
Should cover Everything a design has genuinely standardized on Only components repeated verbatim many times, with no per-instance variation
Risk of overuse Low — more tokens rarely hurts High — turns markup back into opaque class names, undermining utility-first's main benefit

A practical rule of thumb: reach for a new design token liberally (a color, a spacing value, a radius the design uses more than once deserves a name), but reach for a new @apply component sparingly — only once a specific combination has been copy-pasted, unchanged, across enough places that a typo or inconsistency between copies has become a real risk.

Common mistakes

  • Naming tokens after their literal value instead of their role (indigo instead of primary) — this is fine for a raw palette extension, but it means a rebrand requires a find-and-replace across every component that referenced the old color name directly.
  • Building @apply components for things that actually vary per instance (like a card whose padding genuinely differs from screen to screen) — this forces awkward escape-hatch overrides on top of the @apply class, when plain inline utilities would have handled the variation more simply in the first place.
  • Letting two people on the same team independently invent two different tokens for what is visually the same color or spacing value — without a shared, documented token list, "design system" drifts back into "a pile of utilities" with extra steps.
  • Treating the component layer as the design system itself and skipping the token layer — without tokens underneath, every @apply component still has to hardcode raw values, so a rebrand means editing every component definition individually instead of a handful of token values once.