Tailwind ve Blade ile Olceklenebilir Design System
Bir projeye baslarken hep ayni senaryo tekrarlaniyor: "Su butonu biraz daha buyuk yapalim", "Bu renk neydi ya?", "Header'daki spacing neden farkli?" gibi sorular geliyor. Ekip buyudukce, proje buyudukce bu kaos katlanarak artiyor. Cozum? Kendi design system'inizi kurmak.
Bu yazida Tailwind CSS ve Blade component'leri kullanarak nasil olceklenebilir bir design system kurabilecegimizi anlatacagim. Sadece teori degil, production'da kullandigimiz yapiyi paylasacagim.
Neden Design System?
Soyle dusunun: Projenizde 47 farkli buton stili var. Hepsi "biraz farkli" ama hicbiri tutarli degil. Bir gelistirici bg-blue-500 kullanmis, digeri bg-indigo-600, ucuncusu bg-primary diye bir sey tanimlamis ama kimse nerede tanimlandigini bilmiyor.
Design system bu kaosu cozuyor:
- Tutarlilik: Her yerde ayni gorunum
- Hiz: Yeni sayfa olusturmak dakikalar suruyor
- Bakim: Tek yerden degisiklik, her yere yansir
- Onboarding: Yeni gelistirici hemen adapte olur
Uc Katmanli Yaklasim
Benim tercih ettigim yaklasim uc katmandan olusuyor:
1. Tailwind Tokens (Tasarim degiskenleri)
2. Blade Components (Yeniden kullanilabilir UI parcalari)
3. Interactive JS (Alpine.js ile dinamik davranislar)
Bu katmanlar birbirinin uzerine insa ediliyor. Ilk katman olmadan ikinci katman anlamsiz, ikinci katman olmadan ucuncusu havada kaliyor.
Katman 1: Tailwind Token'lari
Her sey tailwind.config.js dosyasindan basliyor. Burada projenizin tasarim dilini tanimliyorsunuz.
// tailwind.config.js
import defaultTheme from 'tailwindcss/defaultTheme';
export default {
content: [
'./resources/**/*.blade.php',
'./resources/**/*.js',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
// ... diger tonlar
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
danger: {
50: '#fef2f2',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
},
success: {
50: '#f0fdf4',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
},
},
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
mono: ['JetBrains Mono', ...defaultTheme.fontFamily.mono],
},
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
},
borderRadius: {
'sm': '0.25rem',
'DEFAULT': '0.375rem',
'md': '0.5rem',
'lg': '0.75rem',
'xl': '1rem',
},
spacing: {
'4.5': '1.125rem',
'13': '3.25rem',
'15': '3.75rem',
'17': '4.25rem',
},
boxShadow: {
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
'dropdown': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
},
},
},
plugins: [],
};
Onemli nokta: Tailwind'in varsayilan renklerini tamamen degistirmeyin, extend ile ekleyin. Boylece gray-500 gibi utility'ler de calismaya devam eder.
Katman 2: Blade Component'leri
Simdi gelelim isin guzel kismina. Blade component'leri ile bu token'lari kullanilabilir UI parcalarina donusturuyoruz.
Dizin Yapisi
resources/views/components/
ui/
button.blade.php
badge.blade.php
input.blade.php
textarea.blade.php
select.blade.php
checkbox.blade.php
toggle.blade.php
layout/
card.blade.php
card-header.blade.php
card-body.blade.php
card-footer.blade.php
section.blade.php
container.blade.php
feedback/
alert.blade.php
modal.blade.php
toast.blade.php
tooltip.blade.php
navigation/
nav-link.blade.php
dropdown.blade.php
breadcrumb.blade.php
pagination.blade.php
data/
table.blade.php
table-header.blade.php
table-row.blade.php
empty-state.blade.php
Bu yapiyi kategorilere ayirmak onemli. 50 component'in hepsi ayni dizinde olursa bulmak zorlasiyor.
Button Component
En cok kullanilan component olan buton ile baslayalim. Bu component 5 variant, 3 boyut ve cesitli state'leri destekliyor.
{{-- resources/views/components/ui/button.blade.php --}}
@props([
'variant' => 'primary',
'size' => 'md',
'type' => 'button',
'href' => null,
'disabled' => false,
'loading' => false,
'icon' => null,
'iconPosition' => 'left',
])
@php
$baseClasses = 'inline-flex items-center justify-center font-medium rounded-md transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
$variants = [
'primary' => 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 active:bg-primary-800',
'secondary' => 'bg-secondary-100 text-secondary-700 hover:bg-secondary-200 focus:ring-secondary-500 active:bg-secondary-300',
'outline' => 'border-2 border-primary-600 text-primary-600 hover:bg-primary-50 focus:ring-primary-500 active:bg-primary-100',
'ghost' => 'text-secondary-600 hover:bg-secondary-100 focus:ring-secondary-500 active:bg-secondary-200',
'danger' => 'bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500 active:bg-danger-800',
];
$sizes = [
'sm' => 'px-3 py-1.5 text-sm gap-1.5',
'md' => 'px-4 py-2 text-sm gap-2',
'lg' => 'px-6 py-3 text-base gap-2.5',
];
$classes = $baseClasses . ' ' . ($variants[$variant] ?? $variants['primary']) . ' ' . ($sizes[$size] ?? $sizes['md']);
@endphp
@if($href)
<a href="{{ $href }}" {{ $attributes->merge(['class' => $classes]) }}>
@if($icon && $iconPosition === 'left')
<x-dynamic-component :component="'icons.' . $icon" class="w-4 h-4" />
@endif
{{ $slot }}
@if($icon && $iconPosition === 'right')
<x-dynamic-component :component="'icons.' . $icon" class="w-4 h-4" />
@endif
</a>
@else
<button
type="{{ $type }}"
{{ $disabled ? 'disabled' : '' }}
{{ $attributes->merge(['class' => $classes]) }}
>
@if($loading)
<svg 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 12h4z"></path>
</svg>
@elseif($icon && $iconPosition === 'left')
<x-dynamic-component :component="'icons.' . $icon" class="w-4 h-4" />
@endif
{{ $slot }}
@if($icon && $iconPosition === 'right' && !$loading)
<x-dynamic-component :component="'icons.' . $icon" class="w-4 h-4" />
@endif
</button>
@endif
Kullanim ornekleri:
{{-- Temel kullanim --}}
<x-ui.button>Kaydet</x-ui.button>
{{-- Variant'lar --}}
<x-ui.button variant="primary">Onayla</x-ui.button>
<x-ui.button variant="secondary">Iptal</x-ui.button>
<x-ui.button variant="outline">Duzenle</x-ui.button>
<x-ui.button variant="ghost">Daha Fazla</x-ui.button>
<x-ui.button variant="danger">Sil</x-ui.button>
{{-- Boyutlar --}}
<x-ui.button size="sm">Kucuk</x-ui.button>
<x-ui.button size="md">Normal</x-ui.button>
<x-ui.button size="lg">Buyuk</x-ui.button>
{{-- Link olarak --}}
<x-ui.button href="/dashboard" variant="primary">Dashboard'a Git</x-ui.button>
{{-- Loading state --}}
<x-ui.button :loading="true">Kaydediliyor...</x-ui.button>
{{-- Form submit --}}
<x-ui.button type="submit" variant="primary">Formu Gonder</x-ui.button>
Card Component
Kartlar her projede lazim. Header, body ve footer olarak uc parcaya ayiriyoruz.
{{-- resources/views/components/layout/card.blade.php --}}
@props([
'padding' => true,
'hover' => false,
'border' => true,
])
@php
$classes = 'bg-white rounded-lg shadow-card';
if ($border) $classes .= ' border border-gray-200';
if ($hover) $classes .= ' hover:shadow-soft transition-shadow duration-200';
@endphp
<div {{ $attributes->merge(['class' => $classes]) }}>
{{ $slot }}
</div>
{{-- resources/views/components/layout/card-header.blade.php --}}
@props([
'border' => true,
])
<div {{ $attributes->merge(['class' => 'px-6 py-4' . ($border ? ' border-b border-gray-200' : '')]) }}>
{{ $slot }}
</div>
{{-- resources/views/components/layout/card-body.blade.php --}}
<div {{ $attributes->merge(['class' => 'px-6 py-4']) }}>
{{ $slot }}
</div>
Kullanimi:
<x-layout.card hover>
<x-layout.card-header>
<h3 class="text-lg font-semibold text-gray-900">Kullanici Bilgileri</h3>
<p class="text-sm text-gray-500">Hesap detaylarini guncelleyin</p>
</x-layout.card-header>
<x-layout.card-body>
{{-- Form alanlari --}}
<div class="space-y-4">
<x-ui.input label="Ad Soyad" name="name" :value="$user->name" />
<x-ui.input label="E-posta" name="email" type="email" :value="$user->email" />
</div>
</x-layout.card-body>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-end gap-3 rounded-b-lg">
<x-ui.button variant="ghost">Iptal</x-ui.button>
<x-ui.button variant="primary" type="submit">Kaydet</x-ui.button>
</div>
</x-layout.card>
Katman 3: Alpine.js ile Interaktivite
Modal, dropdown, toast gibi component'ler icin Alpine.js kullaniyoruz.
{{-- resources/views/components/feedback/modal.blade.php --}}
@props([
'name',
'maxWidth' => 'lg',
'closeable' => true,
])
@php
$maxWidthClasses = [
'sm' => 'sm:max-w-sm',
'md' => 'sm:max-w-md',
'lg' => 'sm:max-w-lg',
'xl' => 'sm:max-w-xl',
'2xl' => 'sm:max-w-2xl',
][$maxWidth] ?? 'sm:max-w-lg';
@endphp
<div
x-data="{ open: false }"
x-on:open-modal-{{ $name }}.window="open = true"
x-on:close-modal-{{ $name }}.window="open = false"
x-on:keydown.escape.window="open = false"
x-show="open"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
>
{{-- Backdrop --}}
<div
x-show="open"
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/75"
@if($closeable) x-on:click="open = false" @endif
></div>
{{-- Modal Panel --}}
<div class="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<div
x-show="open"
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"
class="mx-auto {{ $maxWidthClasses }} w-full bg-white rounded-xl shadow-xl"
>
{{ $slot }}
</div>
</div>
</div>
Modal'i tetiklemek icin:
{{-- Tetikleme --}}
<x-ui.button x-on:click="$dispatch('open-modal-confirm-delete')">
Sil
</x-ui.button>
{{-- Modal --}}
<x-feedback.modal name="confirm-delete" max-width="sm">
<div class="p-6">
<h3 class="text-lg font-semibold text-gray-900">Silme Onay</h3>
<p class="mt-2 text-sm text-gray-600">
Bu islemi geri alamazsiniz. Devam etmek istiyor musunuz?
</p>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="ghost" x-on:click="$dispatch('close-modal-confirm-delete')">
Vazgec
</x-ui.button>
<x-ui.button variant="danger">
Evet, Sil
</x-ui.button>
</div>
</div>
</x-feedback.modal>
Performans Optimizasyonu
CSS Purging
Tailwind v4 ile purging otomatik calisiyor ama dikkat etmeniz gereken seyler var:
// tailwind.config.js
export default {
content: [
// Blade dosyalari
'./resources/views/**/*.blade.php',
// Alpine component'leri
'./resources/js/**/*.js',
// PHP class'larindaki class string'leri
'./app/View/Components/**/*.php',
'./app/Enums/**/*.php',
],
// ...
};
Dikkat: Dinamik class isimleri purge sirasinda kaybolur. Asla boyle yapmayin:
// YANLIS - Tailwind bunu bulamaz
$color = 'red';
<div class="bg-{{ $color }}-500">
// DOGRU - Tam class ismini kullanin
@php
$bgClasses = [
'red' => 'bg-red-500',
'blue' => 'bg-blue-500',
'green' => 'bg-green-500',
];
@endphp
<div class="{{ $bgClasses[$color] }}">
Code Splitting
Buyuk projelerde her sayfanin kendi JS bundle'i olmali:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/css/app.css',
'resources/js/app.js',
],
refresh: true,
}),
],
build: {
rollupOptions: {
output: {
manualChunks: {
'alpine': ['alpinejs'],
},
},
},
},
});
Sik Yapilan Hatalar
1. Erken Over-Engineering
En buyuk hata: Projenin basinda 50 component yazmaya calismak. Baslangicta 5-6 temel component yeterli:
- Button
- Input
- Card
- Alert
- Modal
- Badge
Geri kalanini ihtiyac oldukca ekleyin. YAGNI (You Ain't Gonna Need It) prensibi burada da gecerli.
2. Dark Mode'u Atlama
Dark mode sonradan eklemek cok zor. Bastan planlayamazsaniz en azindan component'lerinizde hardcoded renk kullanmayin:
{{-- YANLIS --}}
<div class="bg-white text-black">
{{-- DOGRU --}}
<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
Tailwind v4'te CSS degiskenleriyle bu daha da kolaylasti:
@theme {
--color-surface: #ffffff;
--color-surface-dark: #1e293b;
--color-text: #0f172a;
--color-text-dark: #f1f5f9;
}
3. Tutarsiz Isimlendirme
Bir yerde btn, baska yerde button, bir yerde card-title, baska yerde cardHeader. Bunu onlemek icin bir convention belirleyin:
- Component isimleri: kebab-case (card-header, nav-link)
- Prop isimleri: camelCase (maxWidth, iconPosition)
- CSS class'lari: Tailwind utility (zaten tutarli)
- Dizin isimleri: tekil (ui, layout, feedback - cogu degil)
4. $attributes Kullanmamak
Blade component'lerinin en guzel ozelligi $attributes bag'i. Bunu kullanmazsaniz esnek component yazamazsiniz:
{{-- YANLIS - class eklenemiyor --}}
<button class="btn-primary">{{ $slot }}</button>
{{-- DOGRU - disaridan class, id, data-* vs eklenebilir --}}
<button {{ $attributes->merge(['class' => 'btn-primary']) }}>{{ $slot }}</button>
Sonuc
Design system kurmak baslangicta zaman alir ama orta-uzun vadede muazzam zaman kazandirir. Tailwind + Blade ikilisi bu is icin mukemmel bir kombinasyon: Tailwind size tutarli bir token sistemi veriyor, Blade component'leri ise bu token'lari yeniden kullanilabilir parcalara donusturuyor.
Kucuk baslayip buyutun. Once temel component'leri yazin, kullanin, sonra ihtiyaca gore genisletin. Mukemmeli hedeflerseniz hicbir zaman baslamazsiniz.
Projenizde design system kullaniyorsaniz veya sorulariniz varsa yorumlarda bulusabiliriz.