[Practical Guide] Building a Design System with Laravel Blade Components — UI Component Design that Balances Reusability, Maintainability, and Accessibility
What You Will Learn in This Article (Key Points)
- How to choose between Blade components (class-based / anonymous) and partial templates (
@include) - How to modularize “fragile UI” elements such as buttons, links, forms, modals, and notifications to keep them robust
- How to design APIs (props / slots) that don’t break even as variants (color, size, state) increase
- Tips for making accessibility the default: focus handling, error display,
aria-*, non–color-dependent cues - Directory structures and operational practices that minimize changes when designs or branding are updated
- Testing perspectives (Feature / Dusk) to prevent UI component regressions
Intended Audience (Who Benefits?)
- Beginner to intermediate Laravel engineers: Those who want to stabilize increasingly painful UI implementations through componentization as screens grow.
- Tech leads / designers: Those looking to formalize the “implementation-side rules” of a design system.
- QA / accessibility specialists: Those who want to standardize the usability of forms and modals and reduce regressions.
- CS / operations teams: Those aiming to reduce inconsistencies in wording, labels, and error messages—and thus reduce inquiries.
Accessibility Level: ★★★★★
All components are designed with the assumptions that they are “fully operable with a keyboard,” “not dependent on color alone,” and “understandable via screen readers.” Concrete examples include
label,aria-describedby,aria-invalid, live regions, and focus management.
1. Introduction: UI Componentization Is Not Just About Visual Consistency
As the number of screens grows in a Laravel application, similar buttons, forms, and alerts appear repeatedly. At first, copy-paste works—but the moment a design or wording change is requested, you often face the nightmare of fixing every page. On top of that, accessibility considerations (labels, error associations, focus handling, color independence) are easy to miss when handled individually per page.
By treating Blade components as the “implementation of a design system,” you can encapsulate UI correctness inside components. If the components are correct, correctness scales as the number of screens grows. That is the goal.
2. Blade Component Basics: When to Use Class Components, Anonymous Components, or Includes
2.1 The Bottom Line (When in Doubt, Use This)
- Anonymous components (
resources/views/components/*.blade.php): Primarily visual components, completed via props. - Class-based components (
app/View/Components): When logic or formatting is required (label generation, ID generation, permission checks, data shaping). - Includes (
@include): Temporary partials when the API is not yet settled.
For a small start, anonymous components are the easiest. When logic grows, promoting them to class components is a natural step.
3. Defining the “Minimum Set” of a Design System
The components that deliver the most value when standardized first are:
- Buttons (including links)
- Form inputs (text, select, checkbox)
- Error displays (summary + field-level)
- Notifications (success / warning / failure)
- Modals (dialogs)
These are where accessibility issues and design-change impacts most often occur. Standardizing just these can dramatically stabilize the user experience.
4. Example: Button Components (Separating Buttons and Links by Responsibility)
Buttons and links may look similar but serve different purposes:
- Buttons: Actions (submit, save, delete)
- Links: Navigation (page transitions)
The more you want to unify their appearance, the more important it is to separate them as components.
4.1 x-button (button)
resources/views/components/button.blade.php
@props([
'variant' => 'primary', // primary|secondary|danger
'size' => 'md', // sm|md|lg
'type' => 'button',
'disabled' => false,
])
@php
$base = 'inline-flex items-center justify-center rounded border font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2';
$sizes = [
'sm' => 'text-sm px-3 py-1.5',
'md' => 'text-base px-4 py-2',
'lg' => 'text-lg px-5 py-3',
][$size] ?? 'text-base px-4 py-2';
$variants = [
'primary' => 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 focus-visible:ring-blue-600',
'secondary' => 'bg-white text-gray-900 border-gray-300 hover:bg-gray-50 focus-visible:ring-gray-400',
'danger' => 'bg-red-600 text-white border-red-600 hover:bg-red-700 focus-visible:ring-red-600',
][$variant] ?? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 focus-visible:ring-blue-600';
$disabledClass = $disabled ? 'opacity-60 cursor-not-allowed' : '';
@endphp
<button type="{{ $type }}"
{{ $attributes->merge(['class' => "$base $sizes $variants $disabledClass"]) }}
@disabled($disabled)
aria-disabled="{{ $disabled ? 'true' : 'false' }}"
>
{{ $slot }}
</button>
Key points
- Focus rings (
focus-visible:ring) are standard. - Disabled states include both visual cues and
disabled/aria-disabled. - “Danger” relies on text meaning (e.g., “Delete”), not color alone.
4.2 x-link-button (anchor)
resources/views/components/link-button.blade.php
@props([
'href',
'variant' => 'primary',
'size' => 'md',
])
<a href="{{ $href }}"
{{ $attributes->merge(['class' => 'inline-flex items-center justify-center rounded border font-medium underline-offset-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2']) }}
>
{{ $slot }}
</a>
Even if styled like a button, keeping links as <a> preserves correct accessibility and expected behavior.
5. Example: Form Input Components (The Label–Help–Error Trio)
Forms become much easier when “label,” “help text,” and “error” are provided as a set.
5.1 x-field (wrapper)
resources/views/components/field.blade.php
@props([
'id',
'label',
'help' => null,
'required' => false,
'error' => null,
])
<div {{ $attributes->merge(['class' => 'mb-4']) }}>
<label for="{{ $id }}" class="block font-medium">
{{ $label }}
@if($required)
<span aria-hidden="true">(Required)</span>
<span class="sr-only">Required</span>
@endif
</label>
@if($help)
<p id="{{ $id }}-help" class="text-sm text-gray-600">{{ $help }}</p>
@endif
<div class="mt-1">
{{ $slot }}
</div>
@if($error)
<p id="{{ $id }}-error" class="text-sm text-red-700">{{ $error }}</p>
@endif
</div>
5.2 x-input
resources/views/components/input.blade.php
@props([
'id',
'name',
'type' => 'text',
'value' => null,
'error' => null,
'helpId' => null,
])
@php
$desc = [];
if ($helpId) $desc[] = $helpId;
if ($error) $desc[] = "{$id}-error";
$describedBy = count($desc) ? implode(' ', $desc) : null;
@endphp
<input
id="{{ $id }}"
name="{{ $name }}"
type="{{ $type }}"
value="{{ old($name, $value) }}"
aria-invalid="{{ $error ? 'true' : 'false' }}"
@if($describedBy) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->merge(['class' => 'w-full border rounded px-3 py-2']) }}
>
5.3 Usage Example (Page Side)
@php $emailError = $errors->first('email'); @endphp
<x-field id="email" label="Email Address" :required="true"
help="Example: hanako@example.com"
:error="$emailError"
>
<x-input id="email" name="email" type="email" :error="$emailError" helpId="email-help"
autocomplete="email" />
</x-field>
With this structure, pages only pass labels and attributes; error associations and screen-reader support are handled automatically.
6. Standardizing Error Summaries (Reducing “Lost” Users)
resources/views/components/error-summary.blade.php
@props(['errors'])
@if($errors->any())
<div id="error-summary" role="alert" tabindex="-1" class="border p-3 mb-4">
<h2 class="font-semibold">Please review the errors below.</h2>
<ul class="list-disc pl-5">
@foreach($errors->all() as $msg)
<li>{{ $msg }}</li>
@endforeach
</ul>
</div>
<script>
(function(){
const el = document.getElementById('error-summary');
if (el) el.focus();
})();
</script>
@endif
Key points
- Automatically focuses when displayed so screen readers announce the state.
- As a component, behavior stays consistent across all forms.
7. Standardizing Notifications (Success / Warning / Error)
resources/views/components/notice.blade.php
@props([
'type' => 'info', // info|success|warning|danger
])
@php
$styles = [
'info' => 'border-blue-200 bg-blue-50 text-blue-900',
'success' => 'border-green-200 bg-green-50 text-green-900',
'warning' => 'border-yellow-200 bg-yellow-50 text-yellow-900',
'danger' => 'border-red-200 bg-red-50 text-red-900',
][$type] ?? 'border-blue-200 bg-blue-50 text-blue-900';
@endphp
<div role="status" aria-live="polite" class="border rounded p-3 mb-4 {{ $styles }}">
{{ $slot }}
</div>
Key points
- Reserve
role="alert"for critical errors only; usestatusfor normal updates. - Always pair color with clear text (e.g., “Saved successfully”).
8. Modals (Dialogs): A Difficult Area, So Protect It with Components
Modals are full of accessibility pitfalls. At minimum:
- Move focus into the modal when opened
- Trap focus within the modal
- Return focus to the trigger when closed
- Allow closing with Esc and prevent background scrolling
Achieving this perfectly with Blade alone is difficult, so minimal JS (Alpine.js, Stimulus) is practical. Below is a conceptual example.
resources/views/components/modal.blade.php
@props(['id', 'title'])
<div x-data="{ open:false }">
<button type="button" @click="open=true" aria-haspopup="dialog" aria-controls="{{ $id }}">
{{ $trigger ?? 'Open' }}
</button>
<div x-show="open" class="fixed inset-0 bg-black/50" aria-hidden="true"></div>
<div x-show="open"
id="{{ $id }}"
role="dialog"
aria-modal="true"
aria-labelledby="{{ $id }}-title"
class="fixed inset-0 flex items-center justify-center p-4"
@keydown.escape.window="open=false"
>
<div class="bg-white rounded p-4 w-full max-w-lg">
<h2 id="{{ $id }}-title" class="text-lg font-semibold">{{ $title }}</h2>
<div class="mt-3">
{{ $slot }}
</div>
<div class="mt-4 flex justify-end gap-2">
<x-button variant="secondary" type="button" @click="open=false">Close</x-button>
{{ $actions ?? '' }}
</div>
</div>
</div>
</div>
9. Directory Structure: Staying Organized as Components Grow
Recommended structure:
resources/views/components/
ui/
button.blade.php
link-button.blade.php
notice.blade.php
modal.blade.php
form/
field.blade.php
input.blade.php
select.blade.php
checkbox.blade.php
error-summary.blade.php
Naming rules
- Use domains like
x-ui.button,x-form.input - Screen-specific components should start as
@include - Promote only universally reusable parts into the design system
10. Managing Variants: Don’t Overload Props
When props grow too numerous, components become hard to use.
- Limit changeable options to
variant,size, andstate - Allow
classoverrides for exceptions - If exceptions grow, create a separate component
Components should be small and strong, not endlessly flexible.
11. Accessibility as a Standard: Review Checklist
- Correct roles (
buttonvsa) labelproperly associated with inputs- Errors linked via
aria-invalidandaria-describedby - Required fields indicated with text, not color alone
- Visible focus rings
- State changes announced via
role="status"/aria-live - Modals close with Esc and trap focus
12. Protecting with Tests: UI Components Are Prone to Regression
Don’t test everything E2E—focus on key areas.
12.1 Feature Tests (Structure)
- Error summaries appear
- Errors exist in session
- Success messages display
12.2 Dusk Tests (Interaction)
- Error summary receives focus
aria-invalid="true"is present- Critical modals open/close and close via Esc
13. Common Pitfalls and How to Avoid Them
- Inconsistent button classes across pages
→ Standardize withx-button. - Placeholder-only forms without labels
→ Enforcex-field. - Errors shown only in red text
→ Encapsulate messages and ARIA linkage. - Mouse-only modals
→ Standardize Esc, focus, and keyboard closure. - Too many props
→ Limit variants; split components if needed.
14. Checklist (For Distribution)
Design
- [ ] Minimum set componentized (button / form / error / notice / modal)
- [ ] Roles separated (button vs link)
- [ ] Directory naming rules defined
Accessibility
- [ ] Labels linked to inputs
- [ ] Required fields indicated by text
- [ ] Errors use
aria-invalidandaria-describedby - [ ] State changes use
role="status" - [ ] Modal Esc, focus, and close flow
Operations
- [ ] Variants kept minimal
- [ ] Exceptions not forced into common components
- [ ] Critical flows covered by Dusk tests
15. Conclusion
Building a design system with Blade components does more than unify appearance—it encapsulates accessibility and correctness inside reusable parts. Starting with just buttons, form inputs, and error summaries already yields strong benefits. Expanding to notices and modals creates a calm, resilient UI that doesn’t get lost as screens grow. Start small, and standardize carefully from the most frequently used components.
Reference Links
-
Laravel Official
-
Accessibility

