Styles That React to State

Static styles aren’t enough. A button needs to look different when disabled. A card needs to highlight when selected. A form field should indicate errors. Svelte makes dynamic classes easy.


What You’ll Learn

  • Apply classes conditionally with class:
  • Use expressions in class attributes
  • Combine static and dynamic classes
  • Build common UI patterns

The class: Directive

What is a Directive?

In Svelte, a directive is a special attribute that gives the framework instructions on how to handle an element. Directives always start with a prefix like on:, bind:, or class:.

The class: directive tells Svelte: “Add or remove this CSS class dynamically depending on whether a condition is true or false.”

How It Works

Think of it like an automatic light switch. When selected is true, the class gets turned on. When selected is false, the class gets turned off. Svelte handles all the adding and removing for you.

Basic syntax:

<div class:className={condition}>
  • className - The CSS class you want to apply
  • condition - A boolean (true/false) that determines if the class should be active

If condition is true, the class is added. If condition is false, the class is removed.

Shorthand Syntax

When your variable name matches your class name exactly, you can use a shorthand:

<!-- Long form -->
<div class:selected={selected}>

<!-- Shorthand (same thing!) -->
<div class:selected>

Both do the same thing: when the selected variable is true, the selected class is applied.

A Complete Example

The simplest way to apply a conditional class:

<!-- example -->
<script>
	let { selected = false } = $props()
</script>

<div class:selected>Content</div>

<style>
	.selected {
		background: lightblue;
	}
</style>

When selected is true, the selected class is added. When false, it’s removed.

The syntax is class:className={condition}. When the variable name matches the class name, you can use the shorthand class:className.


Explicit Conditions

When Names Don’t Match

Sometimes your variable name and CSS class name are different. Maybe your variable is called isActive but your CSS class is .active. In these cases, you need to explicitly tell Svelte which condition controls which class.

Syntax:

<div class:className={variableName}>

The class name goes on the left side of the =, and the variable/condition goes on the right side in curly braces.

Basic Example

<script>
	let isActive = $state(false)
</script>

<!-- Variable name: isActive, Class name: active -->
<button class:active={isActive}>Click me</button>

When isActive is true, the active class is applied. The variable and class have different names, so we need the explicit ={isActive} syntax.

Using Expressions

You can use any expression that evaluates to true or false:

<script>
	let count = $state(0)
</script>

<!-- Apply 'warning' class when count exceeds 10 -->
<span class:warning={count > 10}>Count: {count}</span>

The expression count > 10 is evaluated. If the count is 11 or higher, warning is true and the class is added. If count is 10 or less, the class is removed.

Other expression examples:

  • class:visible={items.length > 0} - Show when there are items
  • class:valid={email.includes('@')} - Basic email check
  • class:even={index % 2 === 0} - Every other item

Multiple Conditions on One Element

You can apply multiple class: directives to the same element:

<script>
	let isActive = $state(false)
	let hasError = $state(false)
	let count = $state(0)
</script>

<div class:active={isActive} class:error={hasError} class:disabled={!isActive && hasError}>
	Status
</div>

What’s happening here:

  • class:active={isActive} - Adds active class when isActive is true
  • class:error={hasError} - Adds error class when hasError is true
  • class:disabled={!isActive && hasError} - Adds disabled class when BOTH conditions are true:
    • !isActive means “not active” (the ! inverts the boolean)
    • && means “AND” (both sides must be true)
    • So: “disabled when NOT active AND has an error”

All three classes can be applied simultaneously if all their conditions are true. They’re evaluated independently.


Combine Static and Dynamic

Best of Both Worlds

In real applications, you typically need both:

  • Static classes - Always applied, define the base appearance
  • Dynamic classes - Applied conditionally, modify the appearance based on state

Think of static classes as the foundation and dynamic classes as the decorations that come and go.

How It Works

You can use the regular class attribute AND multiple class: directives on the same element:

<button
	class="btn btn-primary"          <!-- Static: always present -->
	class:loading={isLoading}        <!-- Dynamic: only when loading -->
	class:disabled={!isValid}        <!-- Dynamic: only when not valid -->
>

Svelte merges everything together. The final class attribute will be:

  • When idle and valid: "btn btn-primary"
  • When loading and valid: "btn btn-primary loading"
  • When not loading and invalid: "btn btn-primary disabled"
  • When loading and invalid: "btn btn-primary loading disabled"

Complete Example

<script>
	let isLoading = $state(false)
	let isValid = $state(true)
</script>

<button class="btn btn-primary" class:loading={isLoading} class:disabled={!isValid}>
	{isLoading ? 'Saving...' : 'Save'}
</button>

<style>
	/* Base button styles - always applied */
	.btn {
		padding: 0.5rem 1rem;
		border-radius: 4px;
		border: none;
		cursor: pointer;
		transition: all 0.2s;
	}

	/* Primary variant - always applied */
	.btn-primary {
		background: #3b82f6;
		color: white;
	}

	/* Loading state - applied conditionally */
	.loading {
		opacity: 0.7;
		cursor: wait;
	}

	/* Disabled state - applied conditionally */
	.disabled {
		opacity: 0.5;
		cursor: not-allowed;
		background: #9ca3af;
	}
</style>

Why Combine Them?

Separation of concerns:

  • Static classes define what the element is (a button, a card, a badge)
  • Dynamic classes define state changes (loading, selected, error)

Reusability:

  • Base styles (.btn, .btn-primary) can be reused across many buttons
  • State modifiers (.loading, .disabled) work with any variant

Maintainability:

  • Easy to see which styles are permanent vs. conditional
  • Changes to base styles don’t affect state logic

Common Pattern: Base + Variant + State

<div class="card card-large" <!-- Base + Size variant -->
	class:selected={isSelected}
	<!-- State: selection -->
	class:featured={isFeatured}
	<!-- State: featured -->
	>
	<!-- content -->
</div>

This creates a flexible system where you can mix and match base styles, variants, and states independently.


Class Expression

Dynamic Class Names with Template Literals

Sometimes you don’t just want to add/remove a class—you want to compute the class name itself. This is perfect for variants like status badges, button sizes, or themes.

How Template Expressions Work

Using curly braces {} inside a class attribute creates a template expression:

class="badge-{status}"

Svelte evaluates the variable inside {} and inserts its value into the string. If status is 'pending', the result is "badge-pending".

Complete Example

<script>
	let status = $state('pending') // 'pending' | 'confirmed' | 'cancelled'
</script>

<span class="badge badge-{status}">
	{status}
</span>

<style>
	.badge {
		padding: 0.25rem 0.5rem;
		border-radius: 4px;
		font-size: 0.875rem;
		font-weight: 600;
		text-transform: uppercase;
	}

	.badge-pending {
		background: #fef3c7;
		color: #92400e;
	}

	.badge-confirmed {
		background: #d1fae5;
		color: #065f46;
	}

	.badge-cancelled {
		background: #fee2e2;
		color: #991b1b;
	}
</style>

How it works:

  • The badge class is always applied (base styles)
  • The template expression badge-{status} dynamically creates:
    • badge-pending when status === 'pending'
    • badge-confirmed when status === 'confirmed'
    • badge-cancelled when status === 'cancelled'

Multiple Variables

You can combine multiple variables:

<script>
	let size = $state('large')     // 'small' | 'medium' | 'large'
	let theme = $state('dark')     // 'light' | 'dark'
</script>

<!-- Generates: "button-large-dark" -->
<button class="button-{size}-{theme}">
	Click me
</button>

When to Use Template Expressions vs. class:

Use class: when:

  • Adding/removing a single class based on a boolean
  • Example: class:active={isActive}

Use template expressions when:

  • The class name depends on a variable’s value
  • Working with enums or string values
  • Example: class="btn-{variant}" where variant can be ‘primary’, ‘secondary’, etc.

Ternary in Class Attribute

Either/Or Situations

Sometimes you need to choose between two completely different class names based on a condition. The ternary operator (? :) is perfect for this.

What is a Ternary Operator?

The ternary operator is a shorthand if/else statement:

condition ? valueIfTrue : valueIfFalse

Reading it out loud: “If condition is true, use valueIfTrue, otherwise use valueIfFalse.”

Basic Example

<script>
	let isExpanded = $state(false)
</script>

<div class={isExpanded ? 'panel expanded' : 'panel collapsed'}>
	<button onclick={() => (isExpanded = !isExpanded)}>
		{isExpanded ? 'Collapse' : 'Expand'}
	</button>
	<p>Content here...</p>
</div>

<style>
	.panel {
		overflow: hidden;
		transition: max-height 0.3s ease;
		border: 1px solid #e5e7eb;
		padding: 1rem;
		border-radius: 4px;
	}

	.expanded {
		max-height: 500px;
	}

	.collapsed {
		max-height: 50px;
	}
</style>

Breaking it down:

  • When isExpanded is true: class="panel expanded"
  • When isExpanded is false: class="panel collapsed"

Why Not Use class:?

You could use class: directives:

<div class="panel" class:expanded={isExpanded} class:collapsed={!isExpanded}>

But the ternary is cleaner when the conditions are mutually exclusive (one OR the other, never both).

Multiple Classes in Ternary

You can include multiple classes in each branch:

<script>
	let mode = $state('edit') // 'edit' | 'view'
</script>

<div class={mode === 'edit' 
	? 'container editable border-blue' 
	: 'container readonly border-gray'
}>
	<!-- content -->
</div>

Combining with Static Classes

Mix static and ternary for maximum flexibility:

<script>
	let isOnline = $state(true)
</script>

<div class="user-status {isOnline ? 'online' : 'offline'}">
	<span class="indicator"></span>
	{isOnline ? 'Available' : 'Away'}
</div>

<style>
	.user-status {
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}
	
	.indicator {
		width: 8px;
		height: 8px;
		border-radius: 50%;
	}
	
	.online .indicator {
		background: #10b981;
	}
	
	.offline .indicator {
		background: #6b7280;
	}
</style>

Multiple Dynamic Classes

When Things Get Complex

When you have many dynamic classes to manage, building a class string manually becomes messy. You need a programmatic approach.

Using $derived with Arrays

Svelte 5’s $derived rune creates computed values that automatically update:

<script>
	let size = $state('medium')
	let variant = $state('primary')
	let isLoading = $state(false)

	let buttonClasses = $derived(
		['btn', `btn-${size}`, `btn-${variant}`, isLoading && 'loading']
			.filter(Boolean)
			.join(' ')
	)
</script>

<button class={buttonClasses}> Click me </button>

Breaking it down:

  1. Array of classes: ['btn', 'btn-medium', 'btn-primary', false]
  2. .filter(Boolean): Removes falsy values (false, null, undefined, empty strings)
    • Result: ['btn', 'btn-medium', 'btn-primary']
  3. .join(' '): Joins array items with spaces
    • Result: "btn btn-medium btn-primary"

How isLoading && 'loading' works:

  • If isLoading is true: Evaluates to 'loading' (the string)
  • If isLoading is false: Evaluates to false (gets filtered out)

This is called short-circuit evaluation in JavaScript.

Helper Function Approach

Create a reusable function to manage classes:

<script>
	// Utility function
	function classNames(...classes) {
		return classes.filter(Boolean).join(' ')
	}

	let isActive = $state(true)
	let isDisabled = $state(false)
	let hasError = $state(false)
</script>

<button class={classNames(
	'btn',
	isActive && 'active',
	isDisabled && 'disabled',
	hasError && 'error'
)}>
	Click
</button>

Why this is better:

  • Clean, readable code
  • Easy to add/remove classes
  • Can be extracted to a utility file and reused

Advanced: Conditional Objects

For even more complex scenarios:

<script>
	function cn(baseClasses, conditionalClasses) {
		const all = [
			baseClasses,
			...Object.entries(conditionalClasses)
				.filter(([_, condition]) => condition)
				.map(([className]) => className)
		]
		return all.join(' ')
	}

	let isActive = $state(true)
	let isLoading = $state(false)
	let hasError = $state(false)
</script>

<button class={cn('btn btn-primary', {
	'active': isActive,
	'loading': isLoading,
	'error': hasError
})}>
	Submit
</button>

Benefits:

  • Base classes are always included
  • Conditional classes are clearly mapped to their conditions
  • Very readable for complex components

Real-World Example: Dynamic Card

<script>
	let { 
		size = 'medium',
		variant = 'default',
		interactive = false,
		selected = false,
		disabled = false 
	} = $props()

	let cardClasses = $derived(
		[
			'card',
			`card-${size}`,
			`card-${variant}`,
			interactive && 'interactive',
			selected && 'selected',
			disabled && 'disabled'
		].filter(Boolean).join(' ')
	)
</script>

<article class={cardClasses}>
	<slot />
</article>

<style>
	.card { /* base styles */ }
	.card-small { padding: 0.5rem; }
	.card-medium { padding: 1rem; }
	.card-large { padding: 1.5rem; }
	
	.card-default { background: white; }
	.card-primary { background: #eff6ff; }
	
	.interactive { cursor: pointer; transition: transform 0.2s; }
	.interactive:hover { transform: translateY(-2px); }
	
	.selected { border: 2px solid #3b82f6; }
	.disabled { opacity: 0.5; pointer-events: none; }
</style>

This pattern scales beautifully as your component grows in complexity.


Apply to BookIt

Let’s apply everything you’ve learned to real BookIt components. These examples show how dynamic classes create polished, interactive UIs.

Service Card Selection

A service card that users can select, with special styling for featured services:

<!-- filename: src/lib/components/ServiceCard.svelte -->
<script>
	let { service, selected = false, featured = false, onclick } = $props()
</script>

<article 
	class="card" 
	class:selected 
	class:featured
	role="button"
	tabindex="0"
	{onclick}
>
	{#if featured}
		<span class="badge">Popular</span>
	{/if}
	
	<h3>{service.name}</h3>
	<p class="description">{service.description}</p>
	<p class="price">${service.price}</p>
	<p class="duration">{service.duration} minutes</p>
</article>

<style>
	.card {
		position: relative;
		padding: 1.5rem;
		border: 2px solid transparent;
		border-radius: 8px;
		background: white;
		cursor: pointer;
		transition: all 0.2s ease;
	}
	
	.card:hover {
		transform: translateY(-2px);
		box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
	}

	/* Selected state - shows user's choice */
	.card.selected {
		border-color: #3b82f6;
		background: #eff6ff;
	}

	/* Featured state - highlights popular services */
	.card.featured {
		border-color: #f59e0b;
		box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2);
	}
	
	/* Both selected AND featured */
	.card.selected.featured {
		border-color: #3b82f6;
		box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
	}
	
	.badge {
		position: absolute;
		top: -8px;
		right: 1rem;
		background: #f59e0b;
		color: white;
		padding: 0.25rem 0.75rem;
		border-radius: 12px;
		font-size: 0.75rem;
		font-weight: 600;
	}
	
	.price {
		font-size: 1.5rem;
		font-weight: 700;
		color: #1f2937;
		margin-top: 1rem;
	}
</style>

Key features:

  • class:selected - Visual feedback for user’s choice
  • class:featured - Highlights popular services
  • Both can be active simultaneously
  • Smooth transitions for professional feel

Form Field Errors

A text field that shows validation errors with clear visual feedback:

<!-- filename: src/lib/components/TextField.svelte -->
<script>
	let { 
		label, 
		error = '', 
		value = $bindable(''),
		required = false,
		...rest 
	} = $props()
	
	// Derived state: has an error if error string is not empty
	let hasError = $derived(error.length > 0)
</script>

<div class="field" class:has-error={hasError}>
	<label>
		{label}
		{#if required}
			<span class="required">*</span>
		{/if}
	</label>
	
	<input 
		bind:value
		{...rest} 
	/>
	
	{#if hasError}
		<span class="error-message">
			<svg width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
				<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
				<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
			</svg>
			{error}
		</span>
	{/if}
</div>

<style>
	.field {
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
		margin-bottom: 1rem;
	}
	
	label {
		font-weight: 500;
		color: #374151;
	}
	
	.required {
		color: #ef4444;
	}
	
	.field input {
		padding: 0.5rem 0.75rem;
		border: 1px solid #d1d5db;
		border-radius: 4px;
		font-size: 1rem;
		transition: all 0.2s;
	}
	
	.field input:focus {
		outline: none;
		border-color: #3b82f6;
		box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
	}

	/* Error state - only applied when hasError is true */
	.field.has-error input {
		border-color: #ef4444;
		background: #fef2f2;
	}
	
	.field.has-error input:focus {
		box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
	}

	.error-message {
		display: flex;
		align-items: center;
		gap: 0.25rem;
		color: #ef4444;
		font-size: 0.875rem;
	}
</style>

How it works:

  • $derived automatically computes hasError when error changes
  • class:has-error={hasError} applies error styling conditionally
  • Red border and background only appear when there’s an error
  • Error message includes an icon for better UX

Button States

A versatile button component with multiple variants, sizes, and states:

<!-- filename: src/lib/components/Button.svelte -->
<script>
	let {
		variant = 'primary',
		size = 'medium',
		loading = false,
		disabled = false,
		fullWidth = false,
		onclick,
		...rest
	} = $props()
</script>

<button 
	class="btn btn-{variant} btn-{size}" 
	class:loading 
	class:full-width={fullWidth}
	disabled={disabled || loading}
	{onclick}
	{...rest}
>
	{#if loading}
		<span class="spinner"></span>
	{/if}
	<slot />
</button>

<style>
	/* Base button - always applied */
	.btn {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		gap: 0.5rem;
		border: none;
		border-radius: 6px;
		font-weight: 500;
		cursor: pointer;
		transition: all 0.2s ease;
	}
	
	.btn:hover:not(:disabled) {
		transform: translateY(-1px);
		box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
	}
	
	.btn:active:not(:disabled) {
		transform: translateY(0);
	}

	/* Variants - dynamic via template expression */
	.btn-primary {
		background: #3b82f6;
		color: white;
	}
	
	.btn-primary:hover:not(:disabled) {
		background: #2563eb;
	}
	
	.btn-secondary {
		background: #6b7280;
		color: white;
	}
	
	.btn-secondary:hover:not(:disabled) {
		background: #4b5563;
	}
	
	.btn-outline {
		background: transparent;
		color: #3b82f6;
		border: 2px solid #3b82f6;
	}
	
	.btn-outline:hover:not(:disabled) {
		background: #eff6ff;
	}

	/* Sizes - dynamic via template expression */
	.btn-small {
		padding: 0.25rem 0.75rem;
		font-size: 0.875rem;
	}
	
	.btn-medium {
		padding: 0.5rem 1rem;
		font-size: 1rem;
	}
	
	.btn-large {
		padding: 0.75rem 1.5rem;
		font-size: 1.125rem;
	}

	/* State modifiers - conditional via class: directive */
	.btn:disabled {
		opacity: 0.5;
		cursor: not-allowed;
		transform: none;
	}

	.loading {
		cursor: wait;
		pointer-events: none;
	}
	
	.full-width {
		width: 100%;
	}

	/* Loading spinner */
	.spinner {
		width: 1em;
		height: 1em;
		border: 2px solid currentColor;
		border-top-color: transparent;
		border-radius: 50%;
		animation: spin 0.6s linear infinite;
	}

	@keyframes spin {
		to {
			transform: rotate(360deg);
		}
	}
</style>

Usage examples:

<!-- Primary button, medium size (defaults) -->
<Button onclick={handleSave}>Save Booking</Button>

<!-- Secondary variant, large size -->
<Button variant="secondary" size="large">Cancel</Button>

<!-- Loading state -->
<Button loading={isSubmitting}>Submit</Button>

<!-- Disabled state -->
<Button disabled={!isValid}>Continue</Button>

<!-- Full width button -->
<Button fullWidth>Book Now</Button>

What makes this powerful:

  • Template expressions (btn-{variant}, btn-{size}) create dynamic class names
  • class: directive (class:loading, class:full-width) handles state
  • Static classes (.btn) provide base styles
  • Everything works together seamlessly

Common Mistakes

1. Forgetting the Colon

The Problem:

<!-- ❌ This sets a STATIC class named "selected" -->
<!-- It's always there, doesn't change -->
<div class="selected">
	Content
</div>

The Solution:

<!-- ✅ This conditionally applies the class -->
<!-- It appears/disappears based on the variable -->
<script>
	let selected = $state(false)
</script>

<div class:selected>
	Content
</div>

Why it matters: Without the colon, you just have a regular CSS class. The magic of class: is that it’s reactive—it updates when your state changes.


2. Conflicting Conditions

The Problem:

<script>
	let hasSuccess = $state(false)
	let hasError = $state(false)
</script>

<!-- ❌ Both could be true at the same time! -->
<div class:success={hasSuccess} class:error={hasError}>
	Message
</div>

If both are true, the element gets class="success error". Your CSS might conflict:

.success { color: green; }
.error { color: red; }  /* Which color wins? */

The Solution:

<script>
	let status = $state('idle') // 'idle' | 'success' | 'error'
</script>

<!-- ✅ Only one can be true at a time -->
<div 
	class:success={status === 'success'} 
	class:error={status === 'error'}
>
	Message
</div>

Or use template expressions:

<div class="message message-{status}">
	Message
</div>

3. Missing Style Definitions

The Problem:

<script>
	let active = $state(false)
</script>

<!-- ❌ Applying a class that isn't styled -->
<div class:active>
	Content
</div>

<style>
	/* Oops! .active is never defined */
	div {
		padding: 1rem;
	}
</style>

The class gets applied to the DOM, but nothing happens visually because there are no styles for .active.

The Solution:

<script>
	let active = $state(false)
</script>

<div class:active>
	Content
</div>

<style>
	/* ✅ Define the styles */
	div {
		padding: 1rem;
		transition: background 0.2s;
	}
	
	.active {
		background: #eff6ff;
		border-left: 4px solid #3b82f6;
	}
</style>

Note: Svelte will warn about unused CSS selectors, but dynamic classes generated via template expressions (class="btn-{variant}") won’t trigger warnings since Svelte can’t track them at compile time.


4. Typos in Class Names

The Problem:

<div class:isActive>  <!-- Variable name: isActive -->
	...
</div>

<style>
	.is-active {  /* ❌ Class name: is-active (with dash) */
		/* Won't match! */
	}
</style>

The Solution:

<!-- Either match exactly -->
<div class:is-active={isActive}>  <!-- ✅ Explicit mapping -->

<!-- Or rename the variable -->
<script>
	let is_active = $state(false)  /* Can't use dashes in JS */
</script>
<div class:active={is_active}>  <!-- ✅ Simpler -->

5. Overcomplicating Simple Cases

The Problem:

<!-- ❌ Too complex for a simple boolean -->
<script>
	let selected = $state(false)
	let classes = $derived(selected ? 'card selected' : 'card')
</script>

<div class={classes}>...</div>

The Solution:

<!-- ✅ Much cleaner -->
<script>
	let selected = $state(false)
</script>

<div class="card" class:selected>...</div>

Rule of thumb: Use class: for simple booleans, use computed classes for complex logic.


Summary

Dynamic classes let styles react to state. Use class:name for simple conditionals, template expressions for computed class names, and helper functions for complex combinations.

Key takeaways:

  • class:name={condition} for conditional classes
  • class:name shorthand when variable matches class name
  • class="btn-{variant}" for computed class names
  • Combine static and dynamic classes freely

Next Steps

The ServiceCard looks good. Continue with Style the Booking Form to create a polished form experience.