The utility-class complaint everyone repeats — "the markup is unreadable" — has never been the real problem. The real problem is that your codebase contains eleven buttons with eight different paddings, nobody can say which one is correct, and changing the brand colour means a find-and-replace across four hundred Blade files. Teams respond by starting a component library, and it dies within a quarter.
It dies for three reasons, always the same three. The components grew a class prop that lets callers override anything. The variants became a thicket of Blade conditionals nobody will touch. And nobody could find the components, because there was no page listing them.
Tailwind CSS v4 changes the first part of this meaningfully. Tokens stopped being a build-time JavaScript object and became CSS variables that the cascade, your Blade components, a Livewire island and a Filament panel can all read from the same place. This guide builds a complete Laravel design system with Tailwind in one app: tokens, components, docs page, tests. Everything below was written against Tailwind CSS v4.3.4 (released 2 November 2026) and Laravel 12/13 — the Blade APIs used here are identical in both.
What you'll learn#
- How to express colour, spacing, radius and motion as
@themetokens, and why semantic aliases beat literal names - The
@themevs@theme inlinedistinction that quietly breaks dark mode, and how to avoid it - How to build class-based Blade components with constructor-promoted typed props and backed enums for variants
- How to resolve Tailwind class conflicts when a caller passes their own classes — including which PHP package actually supports v4
- How to share one token file between Blade, Livewire and a Filament panel
- How to generate a components page from a manifest so new components appear automatically
- How to lock the system in with Pest snapshot tests and an architecture test that bans bespoke markup
What a design system is in a single Laravel app#
Scope matters. This is not a published npm package with a semver contract and external consumers. It is three artefacts living inside one repository, governed by one rule.
The three artefacts are tokens (the values — colour, spacing, radius, shadow, type scale, motion), components (the only approved way to render a pattern), and the page that documents both. The one rule is that nothing bespoke goes in a page template. If a view needs a button, it uses <x-ui.button>. If it needs a button that <x-ui.button> cannot produce, that is a change to the button, not a one-off in the page.
That rule is the entire system. The tokens and components are mechanism; the rule is the thing that actually stops drift. Which is why the testing section near the end is not optional garnish — it is the part that enforces the rule after you stop paying attention.
Most design-system writing is framework-agnostic process theory or Figma-first workflow. The Tailwind-specific material tends to stop at @theme syntax. The Laravel half — typed component classes, how the attribute bag interacts with a variant map, where the docs page lives and how it stays current — is largely unwritten. That gap is what this guide fills.
Define your design tokens with @theme#
In Tailwind v4 the theme lives in CSS. @theme declares custom properties that do two jobs at once: they generate utility classes, and they emit real CSS variables you can read from anywhere — an inline style, a Livewire component, a chart library, a Filament panel.
Start with a primitive palette. Use oklch() rather than hex, because perceptual lightness is what makes a ramp predictable and because color-mix() behaves sanely in it. If you want the full argument for that, I wrote it up in OKLCH and color-mix() for design tokens that generate their own tints.
/* resources/css/tokens.css */
@theme {
/* Primitives — the raw palette. Nothing in a Blade file references these. */
--color-brand-50: oklch(0.97 0.02 265);
--color-brand-100: oklch(0.93 0.05 265);
--color-brand-500: oklch(0.62 0.19 265);
--color-brand-600: oklch(0.55 0.19 265);
--color-brand-700: oklch(0.48 0.17 265);
--color-neutral-0: oklch(1 0 0);
--color-neutral-50: oklch(0.98 0.002 265);
--color-neutral-200: oklch(0.92 0.004 265);
--color-neutral-500: oklch(0.62 0.01 265);
--color-neutral-900: oklch(0.21 0.01 265);
--color-neutral-950: oklch(0.15 0.01 265);
--color-red-500: oklch(0.63 0.21 25);
--color-amber-500: oklch(0.78 0.16 75);
--color-green-600: oklch(0.58 0.15 150);
/* Radius, shadow, motion */
--radius-field: 0.5rem;
--radius-card: 0.875rem;
--shadow-card: 0 1px 2px oklch(0.15 0.01 265 / 0.06), 0 4px 12px oklch(0.15 0.01 265 / 0.06);
--ease-ui: cubic-bezier(0.4, 0, 0.2, 1);
--duration-ui: 150ms;
/* Type scale */
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--text-label: 0.8125rem;
--text-label--line-height: 1.25rem;
--text-label--font-weight: 500;
}
Two things worth knowing. Tailwind only emits variables you actually use; if you need the whole set at runtime — a charting library reading --color-brand-500, say — use @theme static instead. And --color-*: initial; wipes an entire namespace, which is how you delete Tailwind's default palette so nobody can reach for bg-sky-400 by accident. --*: initial; nukes the whole default theme. Both behaviours are documented on the Tailwind theme variables page.
Alias tokens semantically, and never use @theme inline#
Primitives are not an interface. bg-brand-600 tells a reader what colour it is, not what it means, and the day the brand changes you are back to find-and-replace. Add a semantic layer: a second set of design tokens that names the role, pointing at the primitives.
/* resources/css/tokens.css — continued */
@theme {
--color-surface: var(--color-neutral-0);
--color-surface-sunken: var(--color-neutral-50);
--color-border: var(--color-neutral-200);
--color-content: var(--color-neutral-900);
--color-content-muted: var(--color-neutral-500);
--color-accent: var(--color-brand-600);
--color-accent-hover: var(--color-brand-700);
--color-accent-content: var(--color-neutral-0);
--color-danger: var(--color-red-500);
--color-warning: var(--color-amber-500);
--color-success: var(--color-green-600);
}
Now every component uses bg-surface, text-content, border-border, bg-accent. Dark mode becomes a redefinition of the alias layer, and no component changes at all:
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
.dark {
--color-surface: var(--color-neutral-950);
--color-surface-sunken: var(--color-neutral-900);
--color-border: oklch(0.32 0.01 265);
--color-content: var(--color-neutral-50);
--color-content-muted: var(--color-neutral-500);
--color-accent: var(--color-brand-500);
--color-accent-content: var(--color-neutral-950);
}
}
That pattern — themes as token redefinitions rather than per-component dark: classes — is the whole reason CSS-first configuration is worth the migration, and it is covered in more depth in CSS-first dark mode with @custom-variant.
Here is the footgun that will cost you an afternoon. @theme inline does not work for this. With plain @theme, bg-surface compiles to background-color: var(--color-surface) — a live reference, so redefining the variable under .dark flows through. With @theme inline, Tailwind substitutes the value at build time, so bg-surface compiles to the literal white and your dark theme does nothing. The behaviour is reported in tailwindlabs/tailwindcss#18296, where the reporter notes it "only affects the inline theme type" — @theme and @theme static both behave correctly.
The reason this bites so many people is that inline is genuinely the right answer in one narrow case: when a token's value references another variable you want resolved at build time, such as a font stack composed from other tokens. For anything a theme will override at runtime, use plain @theme. The symptom when you get it wrong is not an error — it is dark mode quietly doing nothing, which is a much worse way to find out.
Give components a contract: class-based Blade components with enum props#
Anonymous components with @props are fine for a wrapper. They are the wrong tool for the core set, because @props(['variant' => 'primary']) accepts the string 'primry' without complaint and ships it to production as an unstyled button.
Class-based components give you constructor property promotion, real types, and somewhere to put the variant logic. Start with the enums:
<?php
// app/View/Components/Ui/ButtonVariant.php
namespace App\View\Components\Ui;
enum ButtonVariant: string
{
case Primary = 'primary';
case Secondary = 'secondary';
case Ghost = 'ghost';
case Danger = 'danger';
}
<?php
// app/View/Components/Ui/Size.php
namespace App\View\Components\Ui;
enum Size: string
{
case Sm = 'sm';
case Md = 'md';
case Lg = 'lg';
}
Generate the component with php artisan make:component Ui/Button --no-interaction, then give it a real signature:
<?php
// app/View/Components/Ui/Button.php
namespace App\View\Components\Ui;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class Button extends Component
{
public function __construct(
public ButtonVariant $variant = ButtonVariant::Primary,
public Size $size = Size::Md,
public ?string $href = null,
public bool $loading = false,
) {
}
public function render(): View
{
return view('components.ui.button');
}
}
Laravel resolves variant="danger" into ButtonVariant::Danger automatically, because PHP backed enums are resolvable from the string the attribute carries. A typo now throws a ValueError at render time in local development instead of silently producing a styleless button.
One gotcha from the Blade documentation: a component class may not have public properties or methods named data, render, resolve, resolveView, shouldRender, view, withAttributes or withName. data in particular is an easy one to reach for on a table component.
Express variants as a PHP map, not Blade conditionals#
This is where most Blade component libraries become unreadable. The instinct is to reach for @class with nested conditions in the view, and by the fourth variant it is a thirty-line expression nobody can diff.
Put the map in the class. It is just data:
<?php
// app/View/Components/Ui/Button.php — added to the class
protected const BASE = 'inline-flex items-center justify-center gap-2 font-medium '
.'rounded-field transition-colors duration-(--duration-ui) ease-(--ease-ui) '
.'disabled:opacity-50 disabled:pointer-events-none focus-ring';
protected const VARIANTS = [
'primary' => 'bg-accent text-accent-content hover:bg-accent-hover',
'secondary' => 'bg-surface text-content border border-border hover:bg-surface-sunken',
'ghost' => 'bg-transparent text-content-muted hover:bg-surface-sunken hover:text-content',
'danger' => 'bg-danger text-neutral-0 hover:brightness-95',
];
protected const SIZES = [
'sm' => 'h-8 px-3 text-label',
'md' => 'h-10 px-4 text-sm',
'lg' => 'h-12 px-6 text-base',
];
public function classes(): string
{
return implode(' ', [
self::BASE,
self::VARIANTS[$this->variant->value],
self::SIZES[$this->size->value],
]);
}
Because the keys are enum values, adding a case to ButtonVariant without adding a map entry fails loudly the first time it renders — and a snapshot test over every variant/size pair catches it before that.
Note focus-ring in the base string. That is a custom utility, not a component, because a focus ring is a recurring decoration that several components need and none of them own:
/* resources/css/utilities.css */
@utility focus-ring {
outline: 2px solid transparent;
outline-offset: 2px;
&:focus-visible {
outline-color: var(--color-accent);
}
}
The line between a utility and a component is ownership. A focus ring has no markup, no slots and no variants — it is a property bundle, so it is a utility. A card has structure, so it is a component. The @utility directive covers the mechanics, including why @apply in a component stylesheet is usually the wrong move.
Decide, deliberately, what a caller may override#
Now the hard part, and the part every library gets wrong by accident.
Laravel's attribute bag has no Tailwind awareness whatsoever. $attributes->merge(['class' => 'bg-accent']) concatenates. If the caller passes bg-red-500, both classes land in the HTML and the one that wins is whichever appears later in the compiled stylesheet, not in the markup. There is no first-party resolution — the Laravel 13 Blade documentation does not mention Tailwind once — and there is not going to be one.
This is not a Laravel wart; it bites the ecosystem's own first-party UI kit. The Flux UI customisation docs say it plainly: "because flux applies its own bg-* attributes, both classes will be rendered in the output HTML, but only one will be applied." Their suggested fix is Tailwind v4's trailing-bang important modifier — class="bg-zinc-800!" — followed by the honest caveat that most of the time you would be better served creating a new component.
You have three defensible options. Pick one per component and be consistent.
Option one: refuse the merge. For the core set, this is usually correct. The button's appearance is the button's business; layout is the caller's.
{{-- resources/views/components/ui/button.blade.php --}}
@php($tag = $href ? 'a' : 'button')
<{{ $tag }}
{{ $attributes->except('class')->merge([
'type' => $href ? null : 'button',
'href' => $href,
'aria-busy' => $loading ? 'true' : null,
]) }}
class="{{ $classes() }} {{ $attributes->get('class') }}"
>
@if ($loading)
<x-ui.spinner class="size-4" />
@endif
{{ $slot }}
</{{ $tag }}>
That still appends the caller's classes, but it does so in a position you control, after the variant string — which at least makes the intent legible. For a genuinely closed component, drop $attributes->get('class') entirely and let callers wrap it in a div.
Option two: resolve conflicts properly. This needs a package, and here is the finding that surprised me: the obvious one is wrong for v4. gehrisandro/tailwind-merge-php and its Laravel wrapper have over two million downloads between them, and their READMEs still cap support at Tailwind v3.0–v3.3. The v4-capable fork is tales-from-a-dev/tailwind-merge-php, with marcorieser/tailwind-merge-laravel as a thin Laravel wrapper over it.
composer require marcorieser/tailwind-merge-laravel
<button {{ $attributes->twMerge($classes()) }}>{{ $slot }}</button>
Note that every one of these packages ships the same caveat: $attributes->merge(['class' => ...]) is not supported, because of how Laravel's bag works internally. They add a twMerge() macro to the bag instead. Read the README of whichever you pick before you wire it through forty components.
Option three: Tailwind's important modifier at the call site. <x-ui.button class="bg-surface-sunken!">. Cheap, honest, and visible in review as "someone is fighting the component". A handful of these is fine. Twenty is a design review, not a code review.
Build the core set, one component at a time#
Each remaining component is the same shape: enum props, a variant map, a deliberate merge policy. The two worth showing are the ones with Laravel-specific behaviour.
An input should surface validation errors itself, so no page ever hand-writes error markup:
<?php
// app/View/Components/Ui/Input.php
namespace App\View\Components\Ui;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class Input extends Component
{
public function __construct(
public string $name,
public ?string $label = null,
public Size $size = Size::Md,
public ?string $hint = null,
) {
}
public function render(): View
{
return view('components.ui.input');
}
}
{{-- resources/views/components/ui/input.blade.php --}}
@php($id = $attributes->get('id', $name))
<div class="flex flex-col gap-1.5">
@if ($label)
<label for="{{ $id }}" class="text-label text-content">{{ $label }}</label>
@endif
<input
id="{{ $id }}"
name="{{ $name }}"
@error($name) aria-invalid="true" aria-describedby="{{ $id }}-error" @enderror
{{ $attributes->except('class')->merge(['type' => 'text']) }}
@class([
'w-full rounded-field border bg-surface text-content placeholder:text-content-muted focus-ring',
'h-8 px-2.5 text-label' => $size === Size::Sm,
'h-10 px-3 text-sm' => $size === Size::Md,
'h-12 px-4 text-base' => $size === Size::Lg,
'border-border' => ! $errors->has($name),
'border-danger' => $errors->has($name),
])
/>
@error($name)
<p id="{{ $id }}-error" class="text-label text-danger">{{ $message }}</p>
@enderror
@if ($hint && ! $errors->has($name))
<p class="text-label text-content-muted">{{ $hint }}</p>
@endif
</div>
Pair that with Tailwind's user-valid and user-invalid variants if you want the browser's own validation state to drive styling before a round trip to the server.
A card is the case for slots over props. The moment you find yourself adding title, subtitle, action and footer props, stop:
{{-- resources/views/components/ui/card.blade.php --}}
<div {{ $attributes->class(['bg-surface border border-border rounded-card shadow-card overflow-hidden']) }}>
@isset($header)
<div class="flex items-center justify-between gap-4 px-5 py-4 border-b border-border">
{{ $header }}
</div>
@endisset
<div class="px-5 py-4">{{ $slot }}</div>
@isset($footer)
<div class="px-5 py-3 bg-surface-sunken border-t border-border">{{ $footer }}</div>
@endisset
</div>
The rule: a prop is right when the value is a scalar the component interprets — a variant, a size, a boolean. A slot is right when the value is markup the component merely positions. If you are passing HTML through a prop, you wanted a slot. The same reasoning applies on the interactive side, and Livewire 4's named slots bring the identical model to live components, so a Livewire modal and a Blade card share one mental model.
Share one token file with Livewire and Filament#
Tokens that only exist in the front-end bundle are half a design system. The admin panel drifts, and a year later you have two brands.
Keep tokens in their own file and import it everywhere:
/* resources/css/app.css */
@import 'tailwindcss';
@import './tokens.css';
@import './utilities.css';
@source '../views';
Livewire needs nothing extra — its components compile to Blade and are scanned by @source. The one thing to watch is dynamically constructed class names, which Tailwind's content detection cannot see; @source inline() is the safelist mechanism when you genuinely need one. And if you use @apply inside a scoped component stylesheet, you need the @reference directive or the build will not resolve your theme.
Filament is the part people get wrong. Its styling documentation is explicit: a custom theme is required to use your own Tailwind classes, because Filament's default compiled stylesheet contains only the styles its own UI needs. Generate one and import the same token file into it:
php artisan make:filament-theme admin --no-interaction
/* resources/css/filament/admin/theme.css */
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@import '../../tokens.css';
@source '../../../../app/Filament';
@source '../../../views/filament';
Now a custom Filament field and a front-end <x-ui.input> read --color-accent from the same declaration. Changing the brand is one line in one file. Panel-level branding — logo, colours, fonts — is a separate concern, covered in white-labelling a Filament panel.
Generate the components page instead of writing it#
A hand-written components page is stale within two sprints. Generate it.
Put a manifest next to the components — a single array describing each component and the examples to render:
<?php
// config/design-system.php
return [
'components' => [
'button' => [
'title' => 'Button',
'description' => 'Primary interactive control. Renders an anchor when given href.',
'examples' => [
'<x-ui.button>Save changes</x-ui.button>',
'<x-ui.button variant="secondary">Cancel</x-ui.button>',
'<x-ui.button variant="danger" size="sm">Delete</x-ui.button>',
'<x-ui.button variant="ghost" :loading="true">Saving</x-ui.button>',
],
],
'input' => [
'title' => 'Input',
'description' => 'Text field with label, hint and automatic validation error display.',
'examples' => [
'<x-ui.input name="email" label="Email address" hint="We never share this." />',
],
],
],
];
Render each example twice — once compiled, once as source:
{{-- resources/views/design-system/index.blade.php --}}
<x-layouts.app>
<div class="mx-auto max-w-4xl space-y-16 py-12">
@foreach (config('design-system.components') as $key => $component)
<section id="{{ $key }}" class="space-y-6">
<header class="space-y-1">
<h2 class="text-2xl font-semibold text-content">{{ $component['title'] }}</h2>
<p class="text-sm text-content-muted">{{ $component['description'] }}</p>
</header>
@foreach ($component['examples'] as $example)
<x-ui.card>
<div class="flex flex-wrap items-center gap-4">
{!! Blade::render($example) !!}
</div>
<x-slot:footer>
<pre class="overflow-x-auto text-label text-content-muted"><code>{{ $example }}</code></pre>
</x-slot:footer>
</x-ui.card>
@endforeach
</section>
@endforeach
</div>
</x-layouts.app>
Blade::render() compiles an arbitrary string, which is exactly what you want here and absolutely not what you want anywhere user input can reach. Gate the route accordingly:
// routes/web.php
use Illuminate\Support\Facades\Route;
Route::view('/design-system', 'design-system.index')
->middleware(app()->isProduction() ? ['auth', 'can:viewDesignSystem'] : [])
->name('design-system');
The manifest is the only thing a developer has to update when adding a component, and because the gallery renders from it, a missing entry is visible the moment anyone opens the page.
Advanced patterns and edge cases#
Multi-tenant theming. Because semantic tokens are plain custom properties, a tenant theme is a style attribute on the layout, not a rebuild:
<html style="--color-accent: {{ $tenant->accent_oklch }}; --color-accent-hover: color-mix(in oklch, {{ $tenant->accent_oklch }}, black 12%);">
Only aliases should be overridable this way. Expose the primitive ramp to tenants and you will get a brand colour at 0.02 chroma on a white surface.
Runtime class strings and content detection. Any class name assembled at runtime from database values is invisible to Tailwind's scanner. Keep variant maps as literal strings in PHP constants — which the scanner can see, since it reads .php files under your @source paths — and never build a class name from a column value.
Performance. @theme static emits every variable whether used or not. It is the right call when JavaScript needs to read tokens, and the wrong call by default: it inflates the CSS payload with a few hundred unused declarations.
Accessibility as a token concern. Put focus-ring colour, minimum control height and motion duration in the token layer so prefers-reduced-motion is one override rather than forty. Touch-target sizing is easier to get right at the token level too — Tailwind's pointer variants handle the coarse and fine distinction without duplicating components.
Container queries over breakpoints. A design-system component does not know how wide its parent is, which makes viewport breakpoints the wrong primitive for a component library. Container queries in Tailwind v4 need no plugin and scope responsiveness to the component itself.
Testing this#
Three layers, each catching something the others cannot.
Snapshot tests catch unintended visual change across every variant and size pair:
<?php
// tests/Feature/DesignSystem/ButtonTest.php
use App\View\Components\Ui\ButtonVariant;
use App\View\Components\Ui\Size;
use Illuminate\Support\Facades\Blade;
it('renders every variant and size consistently', function (ButtonVariant $variant, Size $size) {
$html = Blade::render(
'<x-ui.button :variant="$variant" :size="$size">Save</x-ui.button>',
['variant' => $variant, 'size' => $size],
);
expect($html)->toMatchSnapshot();
})->with(
collect(ButtonVariant::cases())->crossJoin(Size::cases())->all()
);
That is twelve snapshots from one test body. If you have not used them before, Pest 4 snapshot testing covers the workflow, including the part everyone gets wrong — reviewing the diff rather than reflexively re-recording it.
An architecture test enforces the one rule:
<?php
// tests/Architecture/DesignSystemTest.php
use Illuminate\Support\Facades\File;
test('page templates never use raw interactive elements', function () {
$offenders = collect(File::allFiles(resource_path('views/pages')))
->filter(fn ($file) => preg_match('/<(button|input|select)[\s>]/i', $file->getContents()))
->map->getRelativePathname()
->values();
expect($offenders)->toBeEmpty();
});
arch('every UI component is a renderable class-based component')
->expect('App\View\Components\Ui')
->classes()
->toExtend(Illuminate\View\Component::class)
->ignoring(['App\View\Components\Ui\ButtonVariant', 'App\View\Components\Ui\Size']);
Pest's arch() helper is the cheapest enforcement you will ever write, and the first of those two tests is the one that actually keeps the system alive.
Finally, a visual pass over the components page in CI. Because every component in every variant renders on one route, a single browser test covers the whole library:
<?php
// tests/Browser/DesignSystemTest.php
it('renders the component gallery without layout regressions', function () {
$page = visit('/design-system');
$page->assertNoJavascriptErrors()
->assertSee('Button')
->screenshot('design-system-light');
$page->script('document.documentElement.classList.add("dark")');
$page->screenshot('design-system-dark');
});
Pest 4's Playwright-backed browser testing makes this a few lines rather than a whole Dusk project.
Common mistakes#
The unbounded class escape hatch. A component that merges anything the caller passes is a div with extra steps. Decide per component, and write the decision down in a comment next to the merge.
Tokens named after their value. --color-blue-600 as a semantic alias means that the day the brand turns green, you rename a token across the codebase — which is the find-and-replace you started this to avoid.
@theme inline for anything a theme overrides. Covered above, but worth repeating because the failure is silent.
Variants as separate booleans. <x-ui.button primary large danger> has sixteen states, most of them nonsense. One enum per axis, always.
A hand-written components page. It is correct for about a fortnight.
Shipping <x-ui.button-v2>. If v1 needs to change, change v1 and fix the call sites; a snapshot test will show you exactly what moved. Two generations of a component in one codebase is the duplication problem you began with, now with version numbers.
Migrating by rewriting everything at once. Inventory the duplicates, start with the component that has the worst spread — it is nearly always the button — convert page by page, and track remaining bespoke markup as a number in CI that is only ever allowed to go down.
Wrapping up#
What you have now is a token file that both the public site and the admin panel read, a set of Blade components whose props are typed and whose variants are data rather than conditionals, a documented position on class merging, a components page that updates itself from a manifest, and three layers of test that fail when somebody quietly reintroduces a bespoke button.
The parts that actually determine whether this survives are the least glamorous: the architecture test, and the manifest. Everything else is mechanism.
Next, if you are still on Tailwind v3, start with the official v3-to-v4 upgrade tool — CSS-first tokens are the whole premise here and they do not exist in v3. If your tokens need tints and shades generated rather than hand-picked, OKLCH with color-mix() will shrink your palette file by two-thirds. And if your components are drifting on narrow screens, container queries are a better fix than another breakpoint variant.
FAQ#
How do I build a design system in a Laravel app?
Start with three artefacts and one rule. The artefacts are a @theme token file in CSS, a set of class-based Blade components under app/View/Components/Ui, and a route that renders every component from a manifest. The rule is that no page template contains bespoke markup for anything a component covers. Enforce the rule with a Pest architecture test, because conventions that are not tested stop being conventions within a quarter.
What is @theme in Tailwind v4?
@theme is the CSS directive that replaces tailwind.config.js for design tokens. Custom properties declared inside it do two things at once: they generate the corresponding utility classes, and they emit real CSS variables on :root that anything can read. Declaring --color-accent: oklch(0.55 0.19 265) gives you bg-accent, text-accent and border-accent, plus a --color-accent variable available to JavaScript, inline styles and other stylesheets.
Should design tokens live in CSS or in a config file?
In Tailwind v4, CSS. A JavaScript config still loads via @config, but it exists for v3 compatibility and the theme() function is deprecated in favour of CSS theme variables. The practical argument is stronger than the official one: a token in CSS is readable by the cascade, so dark mode and tenant theming become variable redefinitions rather than build steps or per-component variant classes.
How do I handle component variants in Blade?
Put the variants in the component class, not the template. Define a backed enum for each axis — variant, size — type the constructor property against it, and keep a const array mapping enum values to utility strings, joined by a classes() method. This replaces nested @class conditionals with data you can read in one glance, and an invalid variant throws a ValueError instead of rendering an unstyled control.
How do I merge Tailwind classes passed into a Blade component?
Laravel's $attributes->merge() concatenates classes with no awareness of Tailwind, so bg-accent and a caller's bg-red-500 both end up in the HTML and stylesheet order decides which wins. Your options are to refuse the merge with $attributes->except('class'), to use a PHP port of tailwind-merge, or to have callers reach for Tailwind's trailing-bang important modifier. If you choose a package, note that gehrisandro/tailwind-merge-php still documents support only up to Tailwind v3.3 — tales-from-a-dev/tailwind-merge-php is the v4-capable fork.
How do I implement dark mode with design tokens?
Declare a @custom-variant dark matching your toggle strategy, then redefine the semantic alias tokens — not the primitives — inside a .dark block in @layer base. Components keep using bg-surface and text-content with no dark: prefixes anywhere. The critical detail is that the aliases must be declared with plain @theme, not @theme inline, because inline substitutes the literal value at build time and the cascade can no longer override it.
How do I test a Blade component library?
Three layers. Snapshot tests over Blade::render() for every variant and size pair catch unintended markup changes. A Pest architecture test asserting that no page template contains a raw <button>, <input> or <select> enforces the rule that keeps the system from rotting. And one browser test against the generated components page gives you visual regression coverage for the entire library in a single route visit.
Do I need a package like Blade UI Kit or Flux instead of building this?
If you want a complete accessible set today and are happy with someone else's opinions, a ready-made library is a reasonable trade. Build your own when the design is genuinely yours, when you want the token layer shared with a Filament panel, or when you want the component set to stay exactly the size of your product. The approaches are not exclusive either — plenty of teams wrap a third-party library behind their own <x-ui.*> facade so the call sites never change if the underlying library does.
Where should the components page live and who should see it?
A single route such as /design-system, rendered from a manifest so it cannot go stale. In local and staging, leave it open; in production, either remove the route or gate it behind auth and a policy. It renders arbitrary Blade through Blade::render(), so the one thing that must never happen is user input reaching the manifest.