Files
Job-Info/svelte-reference/06-STYLING_TAILWIND.md
T
aewing 0b76a4b119 Cleanup: Remove old modes, add Svelte 5 reference library, update orchestrator
- Delete Mode1Test, Mode2Test, Mode3Test, Mode5Test (keep only Mode6Test)
- Delete old documentation, Docker files, and k8s configs
- Add comprehensive Svelte 5 reference library (svelte-reference/)
- Update ModeSwitch orchestrator:
  - Fix port to 3005 (never use 3000/3001)
  - Add API endpoints for mode switching
  - Add bun-types and proper tsconfig
  - Clean up mode folder mappings
2026-01-21 03:56:01 +00:00

16 KiB

Svelte Styling & Tailwind Integration

Overview

Svelte provides scoped CSS by default and integrates seamlessly with Tailwind CSS. This guide covers both approaches and best practices.


🎨 Scoped CSS (Default)

Basic Scoped Styles

<script lang="ts">
  let { variant = 'primary' }: { variant?: 'primary' | 'secondary' } = $props();
</script>

<button class={variant}>
  <slot />
</button>

<style>
  /* These styles are automatically scoped to this component */
  button {
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s;
  }
  
  .primary {
    background: var(--color-primary);
    color: white;
    border: none;
  }
  
  .primary:hover {
    background: var(--color-primary-dark);
  }
  
  .secondary {
    background: transparent;
    color: var(--color-primary);
    border: 2px solid currentColor;
  }
  
  .secondary:hover {
    background: var(--color-primary);
    color: white;
  }
</style>

Global Styles with :global()

<style>
  /* Scoped to this component */
  .container {
    padding: 1rem;
  }
  
  /* Applies globally - use sparingly */
  :global(body) {
    margin: 0;
    font-family: system-ui;
  }
  
  /* Global styles WITHIN a scoped selector */
  .container :global(a) {
    color: var(--color-link);
  }
  
  /* Global class that can be used anywhere */
  :global(.sr-only) {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    border: 0;
  }
</style>

CSS Custom Properties (Variables)

<!-- Define in root layout or app.css -->
<style>
  :root {
    /* Colors */
    --color-primary: #3b82f6;
    --color-primary-dark: #2563eb;
    --color-secondary: #6b7280;
    --color-success: #10b981;
    --color-warning: #f59e0b;
    --color-error: #ef4444;
    
    /* Surfaces */
    --color-background: #ffffff;
    --color-surface: #f9fafb;
    --color-border: #e5e7eb;
    
    /* Text */
    --color-text: #111827;
    --color-text-muted: #6b7280;
    
    /* Spacing */
    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 0.75rem;
    --space-4: 1rem;
    --space-6: 1.5rem;
    --space-8: 2rem;
    
    /* Typography */
    --font-sans: system-ui, -apple-system, sans-serif;
    --font-mono: ui-monospace, monospace;
    
    /* Shadows */
    --shadow-sm: 0 1px 2px rgb(0 0 0 / 0.05);
    --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
    --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
    
    /* Borders */
    --radius-sm: 0.25rem;
    --radius-md: 0.375rem;
    --radius-lg: 0.5rem;
    --radius-full: 9999px;
  }
  
  /* Dark mode */
  :root[data-theme="dark"] {
    --color-background: #111827;
    --color-surface: #1f2937;
    --color-border: #374151;
    --color-text: #f9fafb;
    --color-text-muted: #9ca3af;
  }
</style>

💨 Tailwind CSS Integration

Setup

# Install Tailwind
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./src/**/*.{html,js,svelte,ts}'],
  darkMode: 'class', // or 'media'
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          100: '#dbeafe',
          200: '#bfdbfe',
          300: '#93c5fd',
          400: '#60a5fa',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
          800: '#1e40af',
          900: '#1e3a8a',
        },
      },
      fontFamily: {
        sans: ['Inter var', 'system-ui', 'sans-serif'],
      },
    },
  },
  plugins: [
    require('@tailwindcss/forms'),
    require('@tailwindcss/typography'),
  ],
};

postcss.config.js

export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

app.css

@tailwind base;
@tailwind components;
@tailwind utilities;

/* Custom base styles */
@layer base {
  body {
    @apply bg-white dark:bg-gray-900 text-gray-900 dark:text-white;
  }
  
  h1, h2, h3, h4, h5, h6 {
    @apply font-bold tracking-tight;
  }
}

/* Custom component classes */
@layer components {
  .btn {
    @apply inline-flex items-center justify-center px-4 py-2 
           font-medium rounded-lg transition-colors
           focus:outline-none focus:ring-2 focus:ring-offset-2;
  }
  
  .btn-primary {
    @apply bg-primary-600 text-white hover:bg-primary-700 
           focus:ring-primary-500;
  }
  
  .btn-secondary {
    @apply bg-gray-100 text-gray-700 hover:bg-gray-200
           dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700;
  }
  
  .input {
    @apply block w-full rounded-lg border border-gray-300 
           px-3 py-2 shadow-sm
           focus:border-primary-500 focus:ring-1 focus:ring-primary-500
           dark:border-gray-600 dark:bg-gray-800;
  }
  
  .card {
    @apply rounded-xl bg-white dark:bg-gray-800 
           border border-gray-200 dark:border-gray-700
           shadow-sm overflow-hidden;
  }
}

/* Custom utilities */
@layer utilities {
  .text-balance {
    text-wrap: balance;
  }
  
  .scrollbar-hide {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
  
  .scrollbar-hide::-webkit-scrollbar {
    display: none;
  }
}

🧩 Component Styling Patterns

Pattern 1: Tailwind Component

<!-- Button.svelte -->
<script lang="ts">
  import type { Snippet } from 'svelte';
  import type { HTMLButtonAttributes } from 'svelte/elements';
  
  interface Props extends HTMLButtonAttributes {
    variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
    size?: 'sm' | 'md' | 'lg';
    loading?: boolean;
    children: Snippet;
  }
  
  let {
    variant = 'primary',
    size = 'md',
    loading = false,
    disabled,
    class: className = '',
    children,
    ...rest
  }: Props = $props();
  
  const baseClasses = `
    inline-flex items-center justify-center font-medium rounded-lg
    transition-colors 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-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300',
    danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
    ghost: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
  };
  
  const sizeClasses = {
    sm: 'px-3 py-1.5 text-sm',
    md: 'px-4 py-2 text-sm',
    lg: 'px-6 py-3 text-base'
  };
  
  let classes = $derived(
    `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`.trim()
  );
</script>

<button class={classes} disabled={disabled || loading} {...rest}>
  {#if loading}
    <svg class="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
      <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
      <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
    </svg>
  {/if}
  {@render children()}
</button>

Pattern 2: Class Merging with clsx/tailwind-merge

npm install clsx tailwind-merge
// src/lib/utils/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
<!-- Card.svelte -->
<script lang="ts">
  import { cn } from '$lib/utils/cn';
  import type { Snippet } from 'svelte';
  
  interface Props {
    variant?: 'default' | 'bordered' | 'elevated';
    padding?: 'none' | 'sm' | 'md' | 'lg';
    class?: string;
    children: Snippet;
  }
  
  let { 
    variant = 'default',
    padding = 'md',
    class: className,
    children 
  }: Props = $props();
</script>

<div 
  class={cn(
    'rounded-xl bg-white dark:bg-gray-800',
    {
      'border border-gray-200 dark:border-gray-700': variant === 'default' || variant === 'bordered',
      'shadow-lg': variant === 'elevated',
      'p-0': padding === 'none',
      'p-4': padding === 'sm',
      'p-6': padding === 'md',
      'p-8': padding === 'lg',
    },
    className
  )}
>
  {@render children()}
</div>

Pattern 3: CVA (Class Variance Authority)

npm install class-variance-authority
// src/lib/components/Button/variants.ts
import { cva, type VariantProps } from 'class-variance-authority';

export const buttonVariants = cva(
  // Base classes
  'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
  {
    variants: {
      variant: {
        primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
        secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
        danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
        ghost: 'text-gray-600 hover:bg-gray-100',
        link: 'text-primary-600 underline-offset-4 hover:underline'
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
        icon: 'h-10 w-10'
      }
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md'
    }
  }
);

export type ButtonVariants = VariantProps<typeof buttonVariants>;
<!-- Button.svelte -->
<script lang="ts">
  import { buttonVariants, type ButtonVariants } from './variants';
  import { cn } from '$lib/utils/cn';
  import type { Snippet } from 'svelte';
  import type { HTMLButtonAttributes } from 'svelte/elements';
  
  interface Props extends ButtonVariants, HTMLButtonAttributes {
    class?: string;
    children: Snippet;
  }
  
  let { variant, size, class: className, children, ...rest }: Props = $props();
</script>

<button class={cn(buttonVariants({ variant, size }), className)} {...rest}>
  {@render children()}
</button>

🌓 Dark Mode

With Tailwind

<!-- ThemeToggle.svelte -->
<script lang="ts">
  import { browser } from '$app/environment';
  
  type Theme = 'light' | 'dark' | 'system';
  
  let theme = $state<Theme>('system');
  
  $effect(() => {
    if (!browser) return;
    
    const saved = localStorage.getItem('theme') as Theme | null;
    if (saved) {
      theme = saved;
    }
  });
  
  $effect(() => {
    if (!browser) return;
    
    const root = document.documentElement;
    
    if (theme === 'dark') {
      root.classList.add('dark');
    } else if (theme === 'light') {
      root.classList.remove('dark');
    } else {
      // System preference
      if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        root.classList.add('dark');
      } else {
        root.classList.remove('dark');
      }
    }
    
    localStorage.setItem('theme', theme);
  });
  
  function cycle() {
    const themes: Theme[] = ['light', 'dark', 'system'];
    const current = themes.indexOf(theme);
    theme = themes[(current + 1) % themes.length];
  }
</script>

<button 
  onclick={cycle}
  class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
  aria-label="Toggle theme"
>
  {#if theme === 'light'}
    ☀️
  {:else if theme === 'dark'}
    🌙
  {:else}
    💻
  {/if}
</button>

Prevent Flash of Wrong Theme

<!-- app.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <script>
      // Prevent flash of wrong theme
      (function() {
        const theme = localStorage.getItem('theme');
        if (theme === 'dark' || 
            (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
          document.documentElement.classList.add('dark');
        }
      })();
    </script>
    %sveltekit.head%
  </head>
  <body>
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>

📐 Responsive Design

Tailwind Breakpoints

<div class="
  grid grid-cols-1 
  sm:grid-cols-2 
  md:grid-cols-3 
  lg:grid-cols-4 
  xl:grid-cols-5
  gap-4
">
  {#each items as item}
    <div class="p-4 bg-white rounded-lg shadow">
      {item.name}
    </div>
  {/each}
</div>

<!-- Responsive typography -->
<h1 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold">
  Responsive Heading
</h1>

<!-- Hide/show based on screen size -->
<nav class="hidden md:flex gap-4">
  <!-- Desktop navigation -->
</nav>

<button class="md:hidden">
  <!-- Mobile menu button -->
</button>

Container Queries (Tailwind v3.4+)

<div class="@container">
  <div class="@md:flex @md:gap-4">
    <img class="@md:w-48" src="/image.jpg" alt="" />
    <div>
      <h2 class="@md:text-xl">Title</h2>
      <p class="@md:text-base text-sm">Description</p>
    </div>
  </div>
</div>

Animations

Tailwind Animations

<!-- Built-in animations -->
<div class="animate-spin">Spinning</div>
<div class="animate-pulse">Pulsing</div>
<div class="animate-bounce">Bouncing</div>

<!-- Custom animation in tailwind.config.js -->
<div class="animate-fade-in">Fading in</div>
// tailwind.config.js
export default {
  theme: {
    extend: {
      animation: {
        'fade-in': 'fadeIn 0.3s ease-in-out',
        'slide-up': 'slideUp 0.3s ease-out',
        'slide-down': 'slideDown 0.3s ease-out',
      },
      keyframes: {
        fadeIn: {
          '0%': { opacity: '0' },
          '100%': { opacity: '1' },
        },
        slideUp: {
          '0%': { transform: 'translateY(10px)', opacity: '0' },
          '100%': { transform: 'translateY(0)', opacity: '1' },
        },
        slideDown: {
          '0%': { transform: 'translateY(-10px)', opacity: '0' },
          '100%': { transform: 'translateY(0)', opacity: '1' },
        },
      },
    },
  },
};

Svelte Transitions

<script lang="ts">
  import { fade, fly, slide, scale, blur } from 'svelte/transition';
  import { quintOut } from 'svelte/easing';
  
  let visible = $state(true);
</script>

{#if visible}
  <!-- Fade -->
  <div transition:fade={{ duration: 300 }}>
    Fading content
  </div>
  
  <!-- Fly -->
  <div transition:fly={{ y: 20, duration: 300 }}>
    Flying content
  </div>
  
  <!-- Slide -->
  <div transition:slide={{ duration: 300 }}>
    Sliding content
  </div>
  
  <!-- Scale -->
  <div transition:scale={{ start: 0.95, duration: 300 }}>
    Scaling content
  </div>
  
  <!-- Combined with easing -->
  <div transition:fly={{ y: 20, duration: 400, easing: quintOut }}>
    Smooth flying content
  </div>
{/if}

<!-- In/out separately -->
{#if visible}
  <div 
    in:fly={{ y: 20, duration: 300 }}
    out:fade={{ duration: 200 }}
  >
    Different in/out
  </div>
{/if}

Custom Transition

// src/lib/transitions/index.ts
import { cubicOut } from 'svelte/easing';

export function expandHeight(
  node: Element,
  { duration = 300, easing = cubicOut } = {}
) {
  const style = getComputedStyle(node);
  const height = parseFloat(style.height);
  const paddingTop = parseFloat(style.paddingTop);
  const paddingBottom = parseFloat(style.paddingBottom);
  
  return {
    duration,
    easing,
    css: (t: number) => `
      height: ${t * height}px;
      padding-top: ${t * paddingTop}px;
      padding-bottom: ${t * paddingBottom}px;
      overflow: hidden;
    `
  };
}

📋 Best Practices

Do:

  • Use Tailwind for utility-first styling
  • Create reusable component classes with @layer components
  • Use CSS variables for design tokens
  • Prefer class: directive for conditional classes
  • Use cn() utility for merging classes
  • Keep animations subtle and purposeful

Don't:

  • Mix inline styles with Tailwind randomly
  • Override Tailwind classes with !important
  • Create deeply nested CSS selectors
  • Use @apply for everything (defeats the purpose)
  • Forget dark mode variants
  • Ignore accessibility (focus states, contrast)