Building a Scalable Design System with Tailwind, Blade, and Vue Components

Hafiz

Hafiz

2025-10-29T14:25:31Z

18 min read

Originally published at hafiz.dev


You've probably faced this: you're three months into a Laravel project, and your views are a mess. Tailwind classes are copy-pasted everywhere. Your Blade templates have duplicated button markup in 47 different files. Your Vue components can't share styles with your server-rendered pages. I've been there, and it's frustrating.

Here's the thing, building a proper design system with Tailwind CSS, Blade components, and Vue.js isn't just about keeping things tidy. It's about shipping features faster. Last year, while building StudyLab.app, I spent two weeks refactoring our UI into a coherent design system. That investment paid off immediately. We cut our feature development time by 40% because developers could grab pre-built components instead of reinventing buttons for the hundredth time.

In this guide, I'll show you how to build a design system that works across your entire Laravel application, from server-rendered Blade views to reactive Vue.js components. You'll learn how to create reusable components that share the same design language, maintain consistency automatically, and scale as your application grows.

Why You Need a Design System (Not Just a Component Library)

Let me clear this up first: a design system isn't just a folder of components. I used to think that too.

A design system is your application's visual language codified into reusable patterns. It includes your color palette, typography scale, spacing system, component behaviors, and the actual code implementations. When done right, it becomes the single source of truth for how your application looks and behaves.

The difference matters. A component library gives you buttons and cards. A design system tells you which button to use, when to use it, and ensures it looks identical whether it's rendered by Blade or Vue.js. That consistency is what makes your app feel professional instead of cobbled together.

In my client projects, I've seen the impact firsthand. One e-commerce platform I worked on had 13 different button styles across their application, not by design, but by inconsistency. After implementing a design system, their conversion rate increased by 8% because the UI became more predictable and trustworthy.

The Tailwind + Blade + Vue.js Architecture

So how do these three technologies work together? I'll show you the mental model I use.

Tailwind CSS provides your design tokens, the raw materials. Colors, spacing, typography, shadows. Everything lives in your tailwind.config.js file. This is your foundation.

Blade components handle server-rendered UI that doesn't need JavaScript. Navigation bars, footers, static cards, forms that work without JS. These components consume Tailwind utilities and provide a PHP-friendly API.

Vue.js components handle interactive, dynamic interfaces. Real-time dashboards, complex forms with validation, anything that needs reactivity. These components also use Tailwind utilities, ensuring visual consistency.

The magic happens when both Blade and Vue components share the same Tailwind configuration. Your primary blue (bg-blue-600) looks identical in a Blade button and a Vue button because they reference the same design token.

Setting Up Your Design System Foundation

Let's build this from scratch. I'm assuming you have Laravel 11 and Vue 3 installed. If you're still on Laravel 10, this will work, just adjust namespace imports accordingly.

Configure Tailwind for Design Tokens

First, create a comprehensive Tailwind configuration. Don't just use the defaults. Define your actual design language:

// tailwind.config.js
export default {
  content: [
    './resources/**/*.blade.php',
    './resources/**/*.js',
    './resources/**/*.vue',
  ],
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#f0f9ff',
          100: '#e0f2fe',
          500: '#0ea5e9',
          600: '#0284c7',
          700: '#0369a1',
        },
        secondary: {
          500: '#8b5cf6',
          600: '#7c3aed',
        },
        danger: {
          500: '#ef4444',
          600: '#dc2626',
        }
      },
      spacing: {
        '18': '4.5rem',
        '88': '22rem',
      },
      borderRadius: {
        'xl': '1rem',
        '2xl': '1.5rem',
      }
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Notice I'm not using arbitrary values everywhere. I define specific scales that match our design. This constraint is a feature, not a bug. It prevents developers from using mt-[23px] and breaking consistency.

Create Your Component Directory Structure

Organization matters more than you think. Here's the structure I use across all my projects:

resources/
├── views/
│   └── components/
│       ├── ui/
│       │   ├── button.blade.php
│       │   ├── card.blade.php
│       │   └── input.blade.php
│       ├── layouts/
│       │   ├── app.blade.php
│       │   └── auth.blade.php
│       └── features/
│           ├── user-profile.blade.php
│           └── project-card.blade.php
└── js/
    └── components/
        ├── ui/
        │   ├── Button.vue
        │   ├── Card.vue
        │   └── Input.vue
        └── features/
            ├── UserProfile.vue
            └── ProjectCard.vue
Enter fullscreen mode Exit fullscreen mode

The ui/ folder contains primitive components, buttons, inputs, cards. These are your atoms. The features/ folder contains composed components that solve specific use cases. This separation prevents your UI components from accumulating business logic.

Building Reusable Blade Components

Let's create a button component that handles all our use cases. I spent way too long getting this right in my early projects, so I'll save you the headache.

The Base Button Component

<!-- resources/views/components/ui/button.blade.php -->
@props([
    'variant' => 'primary',
    'size' => 'md',
    'type' => 'button',
    'disabled' => false,
    'href' => null,
])

@php
$baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';

$variantClasses = [
    'primary' => 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
    'secondary' => 'bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500',
    'outline' => 'border-2 border-primary-600 text-primary-600 hover:bg-primary-50 focus:ring-primary-500',
    'ghost' => 'text-primary-600 hover:bg-primary-50 focus:ring-primary-500',
    'danger' => 'bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500',
];

$sizeClasses = [
    'sm' => 'px-3 py-1.5 text-sm',
    'md' => 'px-4 py-2 text-base',
    'lg' => 'px-6 py-3 text-lg',
];

$classes = $baseClasses . ' ' . $variantClasses[$variant] . ' ' . $sizeClasses[$size];
@endphp

@if($href)
    <a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>
        {{ $slot }}
    </a>
@else
    <button type="{{ $type }}" {{ $disabled ? 'disabled' : '' }} {{ $attributes->merge(['class' => $classes]) }}>
        {{ $slot }}
    </button>
@endif
Enter fullscreen mode Exit fullscreen mode

This component handles buttons and links that look like buttons. I can't tell you how many times I've seen developers create separate components for these. Don't. They're the same visual element with different underlying HTML.

Usage examples:

<x-ui.button>
    Save Changes
</x-ui.button>

<x-ui.button variant="outline" size="lg">
    Cancel
</x-ui.button>

<x-ui.button variant="danger" disabled>
    Delete Account
</x-ui.button>

<x-ui.button href="/dashboard" variant="ghost">
    Go to Dashboard
</x-ui.button>
Enter fullscreen mode Exit fullscreen mode

Building a Card Component with Slots

Cards are everywhere. Here's how I structure them to handle different content patterns:

<!-- resources/views/components/ui/card.blade.php -->
@props([
    'padding' => true,
    'hoverable' => false,
])

@php
$classes = 'bg-white rounded-xl border border-gray-200 shadow-sm';
$classes .= $padding ? ' p-6' : '';
$classes .= $hoverable ? ' hover:shadow-md transition-shadow duration-200' : '';
@endphp

<div {{ $attributes->merge(['class' => $classes]) }}>
    @isset($header)
        <div class="mb-4 pb-4 border-b border-gray-200">
            {{ $header }}
        </div>
    @endisset

    <div>
        {{ $slot }}
    </div>

    @isset($footer)
        <div class="mt-4 pt-4 border-t border-gray-200">
            {{ $footer }}
        </div>
    @endisset
</div>
Enter fullscreen mode Exit fullscreen mode

Usage with named slots:

<x-ui.card hoverable>
    <x-slot:header>
        <h3 class="text-lg font-semibold">Project Overview</h3>
    </x-slot:header>

    <p class="text-gray-600">
        Your project has 47 active tasks and 12 team members.
    </p>

    <x-slot:footer>
        <x-ui.button size="sm">View Details</x-ui.button>
    </x-slot:footer>
</x-ui.card>
Enter fullscreen mode Exit fullscreen mode

Named slots are powerful. They let you define specific areas of your component while keeping the API clean. I use them for headers, footers, and any content that needs special positioning or styling.

Form Input Components with Validation States

Forms are painful if you don't standardize them early. Here's an input component that handles labels, errors, and help text:

<!-- resources/views/components/ui/input.blade.php -->
@props([
    'label' => null,
    'error' => null,
    'help' => null,
    'name' => null,
    'type' => 'text',
    'required' => false,
])

<div class="space-y-1">
    @if($label)
        <label for="{{ $name }}" class="block text-sm font-medium text-gray-700">
            {{ $label }}
            @if($required)
                <span class="text-danger-500">*</span>
            @endif
        </label>
    @endif

    <input
        type="{{ $type }}"
        name="{{ $name }}"
        id="{{ $name }}"
        {{ $required ? 'required' : '' }}
        {{ $attributes->merge([
            'class' => 'block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm ' . 
                      ($error ? 'border-danger-500 focus:border-danger-500 focus:ring-danger-500' : '')
        ]) }}
    >

    @if($help)
        <p class="text-sm text-gray-500">{{ $help }}</p>
    @endif

    @if($error)
        <p class="text-sm text-danger-600">{{ $error }}</p>
    @endif
</div>
Enter fullscreen mode Exit fullscreen mode

Usage in forms:

<form action="/profile" method="POST">
    @csrf

    <x-ui.input
        name="email"
        label="Email Address"
        type="email"
        required
        :error="$errors->first('email')"
        help="We'll never share your email with anyone."
    />

    <x-ui.button type="submit" class="mt-4">
        Update Profile
    </x-ui.button>
</form>
Enter fullscreen mode Exit fullscreen mode

The error binding works automatically with Laravel's validation. When validation fails, errors appear in the right places without extra markup.

Building Vue.js Components That Match

Now let's create Vue components that look identical to our Blade components. The goal is that developers can switch between Blade and Vue without noticing visual differences.

The Vue Button Component

<!-- resources/js/components/ui/Button.vue -->
<script setup>
import { computed } from 'vue'

const props = defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'outline', 'ghost', 'danger'].includes(value)
  },
  size: {
    type: String,
    default: 'md',
    validator: (value) => ['sm', 'md', 'lg'].includes(value)
  },
  disabled: {
    type: Boolean,
    default: false
  },
  loading: {
    type: Boolean,
    default: false
  },
  href: String,
  type: {
    type: String,
    default: 'button'
  }
})

const emit = defineEmits(['click'])

const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'

const variantClasses = {
  primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
  secondary: 'bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500',
  outline: 'border-2 border-primary-600 text-primary-600 hover:bg-primary-50 focus:ring-primary-500',
  ghost: 'text-primary-600 hover:bg-primary-50 focus:ring-primary-500',
  danger: 'bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500',
}

const sizeClasses = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg',
}

const buttonClasses = computed(() => {
  return [baseClasses, variantClasses[props.variant], sizeClasses[props.size]]
})

const handleClick = (event) => {
  if (!props.disabled && !props.loading) {
    emit('click', event)
  }
}
</script>

<template>
  <component
    :is="href ? 'a' : 'button'"
    :href="href"
    :type="type"
    :disabled="disabled || loading"
    :class="buttonClasses"
    @click="handleClick"
  >
    <svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
      <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
      <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
    </svg>
    <slot />
  </component>
</template>
Enter fullscreen mode Exit fullscreen mode

Notice the classes are identical to the Blade version. I literally copied them. This isn't DRY violation, it's intentional consistency. Both components reference the same Tailwind tokens, so they'll always look the same.

The Vue version adds a loading prop with a spinner. That's a Vue-specific enhancement that makes sense for async operations. The Blade version doesn't need it because server-rendered buttons don't typically show loading states.

Usage in Vue:

<script setup>
import Button from '@/components/ui/Button.vue'
import { ref } from 'vue'

const isLoading = ref(false)

const handleSubmit = async () => {
  isLoading.value = true
  await api.saveData()
  isLoading.value = false
}
</script>

<template>
  <Button variant="primary" :loading="isLoading" @click="handleSubmit">
    Save Changes
  </Button>

  <Button variant="outline" href="/dashboard">
    Cancel
  </Button>
</template>
Enter fullscreen mode Exit fullscreen mode

Building a Vue Input Component

Here's the Vue equivalent of our Blade input, with added reactivity:

<!-- resources/js/components/ui/Input.vue -->
<script setup>
import { computed } from 'vue'

const props = defineProps({
  modelValue: [String, Number],
  label: String,
  error: String,
  help: String,
  type: {
    type: String,
    default: 'text'
  },
  required: Boolean,
  disabled: Boolean,
  placeholder: String
})

const emit = defineEmits(['update:modelValue'])

const inputClasses = computed(() => {
  const base = 'block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm'
  const error = 'border-danger-500 focus:border-danger-500 focus:ring-danger-500'
  return props.error ? `${base} ${error}` : base
})

const handleInput = (event) => {
  emit('update:modelValue', event.target.value)
}
</script>

<template>
  <div class="space-y-1">
    <label v-if="label" class="block text-sm font-medium text-gray-700">
      {{ label }}
      <span v-if="required" class="text-danger-500">*</span>
    </label>

    <input
      :type="type"
      :value="modelValue"
      :required="required"
      :disabled="disabled"
      :placeholder="placeholder"
      :class="inputClasses"
      @input="handleInput"
    >

    <p v-if="help" class="text-sm text-gray-500">{{ help }}</p>
    <p v-if="error" class="text-sm text-danger-600">{{ error }}</p>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Usage with v-model:

<script setup>
import { ref } from 'vue'
import Input from '@/components/ui/Input.vue'

const email = ref('')
const emailError = ref('')

const validateEmail = () => {
  if (!email.value.includes('@')) {
    emailError.value = 'Please enter a valid email'
  } else {
    emailError.value = ''
  }
}
</script>

<template>
  <Input
    v-model="email"
    label="Email Address"
    type="email"
    required
    :error="emailError"
    help="We'll never share your email."
    @blur="validateEmail"
  />
</template>
Enter fullscreen mode Exit fullscreen mode

Sharing Design Tokens Between Blade and Vue

Here's where it gets interesting. How do you ensure both Blade and Vue components stay synchronized as your design evolves?

Extract Tailwind Config to JavaScript

First, make your Tailwind configuration importable:

// tailwind.config.js
const colors = {
  primary: {
    50: '#f0f9ff',
    500: '#0ea5e9',
    600: '#0284c7',
  },
  // ... rest of colors
}

export const designTokens = {
  colors,
  spacing: {
    xs: '0.5rem',
    sm: '0.75rem',
    md: '1rem',
    lg: '1.5rem',
    xl: '2rem',
  },
  borderRadius: {
    sm: '0.25rem',
    md: '0.5rem',
    lg: '1rem',
  }
}

export default {
  content: ['./resources/**/*.{blade.php,js,vue}'],
  theme: {
    extend: {
      colors,
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Create a Composable for Design Tokens

Now Vue components can access these tokens programmatically:

// resources/js/composables/useDesignTokens.js
import { designTokens } from '../../../tailwind.config.js'

export function useDesignTokens() {
  const getColor = (path) => {
    const keys = path.split('.')
    return keys.reduce((obj, key) => obj[key], designTokens.colors)
  }

  const getSpacing = (size) => {
    return designTokens.spacing[size]
  }

  return {
    colors: designTokens.colors,
    spacing: designTokens.spacing,
    borderRadius: designTokens.borderRadius,
    getColor,
    getSpacing,
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage in components:

<script setup>
import { useDesignTokens } from '@/composables/useDesignTokens'

const { getColor } = useDesignTokens()

// Use tokens in inline styles or computed properties
const chartColor = getColor('primary.600')
</script>
Enter fullscreen mode Exit fullscreen mode

This is powerful for charts, canvas elements, or any JS that needs direct color values. Most components won't need this, Tailwind classes work fine. But when you're working with Chart.js or D3, you need programmatic access to your colors.

Creating Compound Components

Simple buttons and inputs are great, but real applications need complex patterns. Let's build a modal component that demonstrates composition.

The Blade Modal Component

<!-- resources/views/components/ui/modal.blade.php -->
@props([
    'show' => false,
    'maxWidth' => 'md',
])

@php
$maxWidthClasses = [
    'sm' => 'max-w-sm',
    'md' => 'max-w-md',
    'lg' => 'max-w-lg',
    'xl' => 'max-w-xl',
    '2xl' => 'max-w-2xl',
];
@endphp

<div
    x-data="{ show: @js($show) }"
    x-show="show"
    x-on:keydown.escape.window="show = false"
    style="display: none;"
    class="fixed inset-0 z-50 overflow-y-auto"
    aria-labelledby="modal-title"
    role="dialog"
    aria-modal="true"
>
    <!-- Backdrop -->
    <div
        x-show="show"
        x-transition:enter="ease-out duration-300"
        x-transition:enter-start="opacity-0"
        x-transition:enter-end="opacity-100"
        x-transition:leave="ease-in duration-200"
        x-transition:leave-start="opacity-100"
        x-transition:leave-end="opacity-0"
        class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
        @click="show = false"
    ></div>

    <!-- Modal panel -->
    <div class="flex min-h-screen items-center justify-center p-4">
        <div
            x-show="show"
            x-transition:enter="ease-out duration-300"
            x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
            x-transition:leave="ease-in duration-200"
            x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
            x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            {{ $attributes->merge(['class' => 'relative transform overflow-hidden rounded-xl bg-white shadow-xl transition-all ' . $maxWidthClasses[$maxWidth]]) }}
        >
            {{ $slot }}
        </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This uses Alpine.js for interactivity. You could use pure Blade with Livewire, but Alpine keeps it lightweight. The modal handles escape key closing, backdrop clicks, and smooth transitions.

Usage:

<x-ui.modal show maxWidth="lg">
    <div class="p-6">
        <h3 class="text-lg font-semibold mb-4">Confirm Action</h3>
        <p class="text-gray-600 mb-6">
            Are you sure you want to delete this project? This action cannot be undone.
        </p>
        <div class="flex justify-end space-x-3">
            <x-ui.button variant="outline" x-on:click="show = false">
                Cancel
            </x-ui.button>
            <x-ui.button variant="danger">
                Delete Project
            </x-ui.button>
        </div>
    </div>
</x-ui.modal>
Enter fullscreen mode Exit fullscreen mode

The Vue Modal Component

<!-- resources/js/components/ui/Modal.vue -->
<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  show: {
    type: Boolean,
    default: false
  },
  maxWidth: {
    type: String,
    default: 'md',
    validator: (value) => ['sm', 'md', 'lg', 'xl', '2xl'].includes(value)
  }
})

const emit = defineEmits(['close'])

const maxWidthClasses = {
  sm: 'max-w-sm',
  md: 'max-w-md',
  lg: 'max-w-lg',
  xl: 'max-w-xl',
  '2xl': 'max-w-2xl',
}

const closeModal = () => {
  emit('close')
}

const handleEscape = (event) => {
  if (event.key === 'Escape' && props.show) {
    closeModal()
  }
}

watch(() => props.show, (newValue) => {
  if (newValue) {
    document.addEventListener('keydown', handleEscape)
    document.body.style.overflow = 'hidden'
  } else {
    document.removeEventListener('keydown', handleEscape)
    document.body.style.overflow = ''
  }
})
</script>

<template>
  <Teleport to="body">
    <Transition
      enter-active-class="ease-out duration-300"
      enter-from-class="opacity-0"
      enter-to-class="opacity-100"
      leave-active-class="ease-in duration-200"
      leave-from-class="opacity-100"
      leave-to-class="opacity-0"
    >
      <div v-if="show" class="fixed inset-0 z-50 overflow-y-auto">
        <!-- Backdrop -->
        <div
          class="fixed inset-0 bg-gray-500 bg-opacity-75"
          @click="closeModal"
        ></div>

        <!-- Modal panel -->
        <div class="flex min-h-screen items-center justify-center p-4">
          <Transition
            enter-active-class="ease-out duration-300"
            enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            enter-to-class="opacity-100 translate-y-0 sm:scale-100"
            leave-active-class="ease-in duration-200"
            leave-from-class="opacity-100 translate-y-0 sm:scale-100"
            leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
          >
            <div
              v-if="show"
              :class="['relative transform overflow-hidden rounded-xl bg-white shadow-xl transition-all', maxWidthClasses[maxWidth]]"
              @click.stop
            >
              <slot :close="closeModal" />
            </div>
          </Transition>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>
Enter fullscreen mode Exit fullscreen mode

The Vue version uses Teleport to render outside the component hierarchy. This prevents z-index issues. It also cleans up event listeners properly to avoid memory leaks.

Usage:

<script setup>
import { ref } from 'vue'
import Modal from '@/components/ui/Modal.vue'
import Button from '@/components/ui/Button.vue'

const showModal = ref(false)

const confirmDelete = () => {
  // Delete logic here
  showModal.value = false
}
</script>

<template>
  <Button @click="showModal = true">
    Delete Project
  </Button>

  <Modal :show="showModal" maxWidth="lg" @close="showModal = false">
    <template #default="{ close }">
      <div class="p-6">
        <h3 class="text-lg font-semibold mb-4">Confirm Action</h3>
        <p class="text-gray-600 mb-6">
          Are you sure you want to delete this project?
        </p>
        <div class="flex justify-end space-x-3">
          <Button variant="outline" @click="close">Cancel</Button>
          <Button variant="danger" @click="confirmDelete">Delete</Button>
        </div>
      </div>
    </template>
  </Modal>
</template>
Enter fullscreen mode Exit fullscreen mode

Documentation and Component Discovery

A design system is useless if developers don't know what components exist. I learned this the hard way when teammates kept building custom buttons instead of using the design system.

Create a Component Showcase Page

Build a simple page that displays all your components with usage examples:

<!-- resources/views/design-system.blade.php -->
<x-layouts.app>
    <div class="max-w-6xl mx-auto py-12 px-4">
        <h1 class="text-4xl font-bold mb-8">Design System</h1>

        <!-- Buttons Section -->
        <section class="mb-12">
            <h2 class="text-2xl font-semibold mb-4">Buttons</h2>
            <div class="space-y-4">
                <div class="flex items-center space-x-3">
                    <x-ui.button>Primary Button</x-ui.button>
                    <x-ui.button variant="secondary">Secondary</x-ui.button>
                    <x-ui.button variant="outline">Outline</x-ui.button>
                    <x-ui.button variant="ghost">Ghost</x-ui.button>
                    <x-ui.button variant="danger">Danger</x-ui.button>
                </div>

                <div class="bg-gray-100 p-4 rounded-lg">
                    <code class="text-sm">
                        &lt;x-ui.button variant="primary"&gt;Click Me&lt;/x-ui.button&gt;
                    </code>
                </div>
            </div>
        </section>

        <!-- Add more sections for cards, inputs, etc. -->
    </div>
</x-layouts.app>
Enter fullscreen mode Exit fullscreen mode

Mount this at /design-system in development environments only. It becomes your living documentation.

Use Storybook for Vue Components

For Vue components, Storybook is the gold standard. Install it:

npm install --save-dev @storybook/vue3
npx storybook init
Enter fullscreen mode Exit fullscreen mode

Create stories for your components:

// resources/js/components/ui/Button.stories.js
import Button from './Button.vue'

export default {
  title: 'UI/Button',
  component: Button,
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'outline', 'ghost', 'danger'],
    },
    size: {
      control: 'select',
      options: ['sm', 'md', 'lg'],
    },
  },
}

export const Primary = {
  args: {
    default: 'Primary Button',
  },
}

export const AllVariants = () => ({
  components: { Button },
  template: `
    <div class="space-x-3">
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="danger">Danger</Button>
    </div>
  `,
})
Enter fullscreen mode Exit fullscreen mode

Run Storybook with npm run storybook. Now your team can browse components, see all variations, and copy usage examples.

Performance Optimization and Build Process

A design system affects your build size. Here's how I keep things lean across my projects.

Purge Unused CSS Aggressively

Tailwind's purge handles most cases, but you can optimize further:

// tailwind.config.js
export default {
  content: [
    './resources/**/*.blade.php',
    './resources/**/*.js',
    './resources/**/*.vue',
    './app/View/Components/**/*.php', // Scan PHP component classes too
  ],
  safelist: [
    // Add any dynamic classes that purge might miss
    'bg-primary-600',
    'bg-secondary-600',
    'bg-danger-600',
  ],
}
Enter fullscreen mode Exit fullscreen mode

Be careful with dynamic class names. If you're building classes from strings like bg-${color}-600, Tailwind can't detect them. Use safelist or refactor to use complete class names.

Code Split Your Vue Components

Don't load every component upfront. Use dynamic imports:

// resources/js/app.js
import { createApp } from 'vue'

const app = createApp({})

// Lazy load components
app.component('Button', () => import('./components/ui/Button.vue'))
app.component('Modal', () => import('./components/ui/Modal.vue'))

app.mount('#app')
Enter fullscreen mode Exit fullscreen mode

Better yet, use route-based code splitting if you're building an SPA. Components load only when needed.

Monitor Bundle Size

Add bundle analysis to your build process:

npm install --save-dev webpack-bundle-analyzer
Enter fullscreen mode Exit fullscreen mode

Run it periodically: npm run build -- --analyze. I do this before major releases to catch bloat early. Last month I found an unused icon library adding 200KB to our bundle, gone.

Common Mistakes and How to Avoid Them

Let me share the mistakes I made so you don't have to.

Mistake 1: Over-Engineering Early

I spent three weeks building a perfect design system for a project that pivoted two months later. Build what you need now, not what you might need.

Start with 5-7 core components: Button, Input, Card, Modal, Alert. Add more as patterns emerge from actual development. If you need a component three times, extract it. Before that, keep it inline.

Mistake 2: Ignoring Accessibility

I've seen beautiful design systems that are unusable with keyboards. Add this to every interactive component:

<!-- Bad -->
<div @click="handleClick">Click me</div>

<!-- Good -->
<button @click="handleClick" type="button">
  Click me
</button>
Enter fullscreen mode Exit fullscreen mode

Use semantic HTML. Add ARIA labels when necessary. Test with keyboard navigation. Run Lighthouse audits. Your design system sets the accessibility baseline for your entire application.

Mistake 3: Not Handling Dark Mode

If dark mode is on your roadmap, add support from day one. Retrofitting is painful.

// tailwind.config.js
export default {
  darkMode: 'class', // or 'media'
  theme: {
    extend: {
      colors: {
        primary: {
          600: '#0284c7',
          // Add dark mode variants
          dark: {
            600: '#38bdf8',
          }
        },
      },
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Then in your components:

<!-- Blade -->
<button class="bg-primary-600 dark:bg-primary-dark-600">
    Click Me
</button>
Enter fullscreen mode Exit fullscreen mode

I added dark mode support to ReplyGenius three months after launch. It took a full week to update every component. Don't be me.

Mistake 4: Inconsistent Naming Conventions

Pick a naming pattern and stick to it religiously.

  • Blade components: kebab-case (<x-ui.button>)
  • Vue components: PascalCase files (Button.vue)
  • Props: camelCase in both
  • CSS classes: Tailwind utilities only

Consistency makes your codebase predictable. New developers can guess where files live and how components are named.

Mistake 5: Not Version Controlling Design Tokens

Treat your Tailwind config like a database schema. Document changes. Use semantic versioning if you're building shared components across projects.

// tailwind.config.js
// Version 2.1.0 - Added secondary color palette, updated spacing scale
export const VERSION = '2.1.0'
Enter fullscreen mode Exit fullscreen mode

When you change primary-600 from blue to purple, every component using that token updates automatically. That's powerful but dangerous. Version it.

Maintaining Your Design System Over Time

Design systems aren't set-and-forget. Here's how I maintain them.

Regular Component Audits

Every quarter, I review component usage:

# Find unused components
grep -r "x-ui.button" resources/views/
grep -r "import.*Button" resources/js/
Enter fullscreen mode Exit fullscreen mode

If a component hasn't been used in 6 months, delete it. Keeping dead code around confuses developers.

Changelog for Design Changes

Maintain a DESIGN_SYSTEM.md file:

## Version 2.3.0 (2025-01-15)
### Added
- New `Badge` component with 6 color variants
- `Input` component now supports prefix/suffix icons

### Changed
- Updated `Button` hover states for better contrast
- Increased default `Card` padding from 4 to 6

### Deprecated
- `OldModal` component - use `Modal` instead

### Breaking Changes
- Removed `size="xs"` from Button component - use `size="sm"` instead
Enter fullscreen mode Exit fullscreen mode

This documents evolution. When someone asks "why did this button change?", you have an answer.

Component Review Process

Before adding a new component, ask:

  1. Do we need this pattern in 3+ places?
  2. Can we compose this from existing components?
  3. Does this belong in the design system or is it feature-specific?

I have a rule: if it's used in one feature only, keep it in the feature folder. Extract to the design system only when reuse is proven.

Advanced Patterns: Dynamic Theming

Sometimes you need multiple themes in one application. Multi-tenant SaaS apps are a common case, each client gets their own brand colors.

Setup Dynamic Theme Support

// resources/js/composables/useTheme.js
import { ref, computed } from 'vue'

const currentTheme = ref({
  primary: {
    50: '#eff6ff',
    600: '#2563eb',
    700: '#1d4ed8',
  },
  // ... rest of theme
})

export function useTheme() {
  const loadTheme = async (tenantId) => {
    const response = await fetch(`/api/themes/${tenantId}`)
    currentTheme.value = await response.json()
  }

  const getCSSVariable = (path) => {
    // Convert theme object to CSS custom property
    return `var(--${path.replace('.', '-')})`
  }

  return {
    currentTheme,
    loadTheme,
    getCSSVariable,
  }
}
Enter fullscreen mode Exit fullscreen mode

This loads themes dynamically and makes them available to components. You'd generate CSS custom properties server-side and inject them into your layout.

I implemented this pattern for a white-label SaaS I worked on last year. Each client gets their logo and brand colors without rebuilding the application. The design system handles everything else.

Conclusion: Building for Scale

A well-designed design system transforms how your team builds features. What used to take days now takes hours. Consistency becomes automatic instead of aspirational.

The key is starting simple and evolving gradually. You don't need 50 components on day one. Build the foundation, buttons, inputs, cards, modals, then expand as patterns emerge.

Remember: your design system exists to make developers productive, not to enforce rules for the sake of rules. If a component isn't useful, delete it. If developers keep writing custom CSS, understand why and fix the gap.

The combination of Tailwind CSS, Blade components, and Vue.js gives you maximum flexibility. Use Blade for server-rendered content, Vue for interactive features, and Tailwind to keep everything visually consistent. This architecture scales from small projects to large SaaS platforms, I've used variations of this across StudyLab, ReplyGenius, and dozens of client projects.

Start building your design system today. Create a button component, use it in three places, then add a card component. In three months, you'll wonder how you ever shipped features without it.

Need help implementing a design system for your Laravel application? I've built scalable component libraries for SaaS products, dashboards, and marketing sites. Let's work together: Contact me