Site icon IT & Life Hacks Blog|Ideas for learning and practicing

[Practical Guide] Building a Design System with Laravel Blade Components — UI Component Design that Balances Reusability, Maintainability, and Accessibility

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

[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:

  1. Buttons (including links)
  2. Form inputs (text, select, checkbox)
  3. Error displays (summary + field-level)
  4. Notifications (success / warning / failure)
  5. 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; use status for 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, and state
  • Allow class overrides 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 (button vs a)
  • label properly associated with inputs
  • Errors linked via aria-invalid and aria-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 with x-button.
  • Placeholder-only forms without labels
    → Enforce x-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-invalid and aria-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

Exit mobile version