12 min read

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

Learn how to create a reusable design system combining Tailwind CSS, Laravel Blade components, and Vue.js for maximum efficiency and consistency.

Tailwind CSS Laravel Blade Vue.js Design System UI Components
Building a Scalable Design System with Tailwind, Blade, and Vue Components

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',
      }
    },
  },
}

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

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

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>

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>

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>

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>

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>

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>

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>

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>

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>

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,
    },
  },
}

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,
  }
}

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>

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>

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>

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>

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>

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>

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

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>
  `,
})

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',
  ],
}

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')

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

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>

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',
          }
        },
      },
    },
  },
}

Then in your components:

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

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'

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/

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

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,
  }
}

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


Need Help With Your Laravel Project?

I specialize in building custom Laravel applications, process automation, and SaaS development. Whether you need to eliminate repetitive tasks or build something from scratch, let's discuss your project.

⚡ Currently available for 2-3 new projects

Hafiz Riaz

About Hafiz Riaz

Full Stack Developer from Turin, Italy. I build web applications with Laravel and Vue.js, and automate business processes. Creator of ReplyGenius, StudyLab, and other SaaS products.

View Portfolio →