· Admin

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.