0b76a4b119
- 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
16 KiB
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
@applyfor everything (defeats the purpose) - Forget dark mode variants
- Ignore accessibility (focus states, contrast)