Your Stylesheet Is a State Machine

Every UI component lives in states. A notification can be unread, read, or archived. A file upload can be idle, uploading, complete, or failed. A user’s subscription tier determines which features are highlighted. An accordion is open or closed.

Your CSS defines what each state looks like. Dynamic class binding defines when each state is active. That separation is worth sitting with for a moment, because it’s what keeps large UIs manageable. The visual definition of “an overdue task card” lives in one place — your <style> block. The logic that decides whether the card is overdue lives somewhere else entirely — in your data. Dynamic class binding is the thin, reactive bridge between them.

Svelte gives you two mechanisms to build that bridge: the class attribute (the modern, preferred approach) and the class: directive (simpler, but less composable). This article covers both — what they do, how they differ, and when each one is the right tool.


The class Attribute

Since Svelte 5.16, the class attribute accepts far more than strings. It accepts objects, arrays, and nested combinations of both — processed internally by clsx, a tiny library that converts those structures into a final class string. Understanding this unlocks a much more expressive way of describing component state.

Primitive Values

The simplest dynamic class is a plain expression — a ternary that picks between two class names based on a condition:

<script>
	let plan = $state('free') // 'free' | 'pro' | 'enterprise'
</script>

<span class={plan === 'free' ? 'badge-free' : 'badge-paid'}>
	{plan}
</span>

This is perfectly fine for binary cases. But it breaks down as soon as you have more than two possible states, because nested ternaries are hard to read and even harder to extend. That’s where the object syntax steps in.

Before going further, there’s a footgun in the primitive form that trips people up.

Warning

Falsy values are stringified, not omitted. Writing class={false} produces class="false" in the DOM — a literal string, not an empty attribute. Only undefined and null actually omit the class attribute entirely. This is a historical quirk that will likely be corrected in a future Svelte version. For now: use the object syntax to toggle classes (it handles this correctly), or use null instead of false when you want no class at all.

Object Syntax

Pass an object to class where each key is a class name and each value is a boolean expression. Keys with truthy values are included in the output; keys with falsy values are silently dropped. The result is exactly the class string you’d have constructed by hand, without the string manipulation:

<script>
	type TaskStatus = 'todo' | 'in-progress' | 'blocked' | 'done'

	let {
		status,
		dueDate,
		children
	}: {
		status: TaskStatus
		dueDate: Date
		children: import('svelte').Snippet
	} = $props()

	let isOverdue = $derived(status !== 'done' && dueDate < new Date())
</script>

<!-- The object reads like a declaration of everything this card can be -->
<div
	class={{
		'task-card': true,
		'task-done': status === 'done',
		'task-blocked': status === 'blocked',
		'task-overdue': isOverdue
	}}
>
	{@render children()}
</div>

<style>
	.task-card {
		padding: 1rem;
		border: 1px solid #e5e7eb;
		border-radius: 8px;
		background: white;
	}

	.task-done {
		opacity: 0.6;
		border-style: dashed;
	}

	.task-blocked {
		border-color: #fbbf24;
		background: #fffbeb;
	}

	.task-overdue {
		border-color: #ef4444;
		background: #fef2f2;
	}
</style>

Notice 'task-card': true — that’s one way to include a permanent base class inside the object. In practice though, most people combine a static class string for the base with a dynamic object for the state modifiers, which you’ll see in the patterns section.

What makes the object form so readable is that it functions like a checklist: you can scan the keys and immediately understand every state the element can be in. Extending it later — adding a task-pinned state, say — is one line. You don’t have to reconstruct a conditional string or find the right place in a chain of ternaries.

Array Syntax

Arrays unlock something the object syntax can’t do: attaching multiple classes to a single condition. Each element in the array is either a truthy string (which gets included) or a falsy value (which gets filtered out by clsx). Since someCondition && 'class-a class-b' evaluates to either the string or false, the array form handles it naturally:

<script>
	let isPlaying = $state(false)
	let isBuffering = $state(false)
	let isLiked = $state(false)
</script>

<div
	class={[
		'track-row',
		isPlaying && 'track-active track-highlight',
		isBuffering && 'track-buffering animate-pulse',
		isLiked && 'track-liked'
	]}
>
	<!-- track content -->
</div>

<style>
	.track-row {
		display: flex;
		align-items: center;
		padding: 0.625rem 1rem;
		border-radius: 6px;
		gap: 0.75rem;
	}

	.track-active {
		background: #eff6ff;
	}
	.track-highlight {
		font-weight: 600;
		color: #1d4ed8;
	}
	.track-buffering {
		opacity: 0.7;
		pointer-events: none;
	}
	.track-liked .heart-icon {
		color: #ec4899;
	}
</style>

The key pattern here is isPlaying && 'track-active track-highlight'. When isPlaying is true, both track-active and track-highlight are added together. When it’s false, neither is. This is especially valuable for utility-first CSS like Tailwind, where a semantic state like “uploading” might require five or six utility classes at once:

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

<button
	class={[
		'upload-btn',
		uploadState === 'idle' && 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50',
		uploadState === 'uploading' && 'bg-blue-50 border border-blue-300 text-blue-700 cursor-wait',
		uploadState === 'success' && 'bg-green-50 border border-green-300 text-green-700',
		uploadState === 'error' && 'bg-red-50 border border-red-300 text-red-700'
	]}
>
	{#if uploadState === 'uploading'}
		Uploading…
	{:else if uploadState === 'success'}
		Done!
	{:else if uploadState === 'error'}
		Failed — try again
	{:else}
		Upload file
	{/if}
</button>

Each semantic state activates a group of utility classes together. Adding a new state is a single new line in the array — no string manipulation required, and you can read exactly which utilities apply to which state at a glance.

Arrays and objects can be freely combined and nested. clsx flattens everything:

<div
	class={[
		'notification',
		{ 'notification-unread': !isRead, 'notification-pinned': isPinned },
		priorityClass
	]}
>

The class: Directive

The class: directive predates the clsx-powered attribute and was, for a long time, the primary tool for conditional classes in Svelte. It’s still fully supported — but the official Svelte documentation now recommends preferring the class attribute with object or array syntax for new code, because it’s more powerful and composable.

Info

The Svelte docs say directly: “Unless you’re using an older version of Svelte, consider avoiding class:, since the attribute is more powerful and composable.” That said, class: is not going away, and for single toggles it’s genuinely clean. Know both approaches and choose based on what reads better in each situation.

Basic Syntax

The directive takes the form class:name={condition}, where name is the class to add when condition is truthy and remove when it’s falsy:

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

<aside class:expanded={sidebarOpen}>
	<!-- sidebar content -->
</aside>

<main class:offset={sidebarOpen}>
	<!-- page content -->
</main>

<style>
	aside {
		width: 64px;
		transition: width 0.2s ease;
	}

	aside.expanded {
		width: 240px;
	}

	main {
		margin-left: 64px;
		transition: margin-left 0.2s ease;
	}

	main.offset {
		margin-left: 240px;
	}
</style>

The equivalent using the class attribute object form would be class={{ expanded: sidebarOpen }}. Both are correct — use whichever feels more natural in context.

Shorthand Form

When the variable name matches the class name exactly eg. class:focused={focused} class:disabled={disabled}, you can drop the ={condition} assignment:

<script lang="ts">
	let focused = $state(false)
	let disabled = $state(false)
</script>

<!-- Long form — class name and expression written out explicitly -->

<!-- <div class:focused={focused} class:disabled={disabled}>
	<input onfocus={() => (focused = true)} onblur={() => (focused = false)} />
</div> -->

<!-- Shorthand — variable name matches class name, so ={varName} can be omitted -->

<div class:focused class:disabled>
	<input onfocus={() => (focused = true)} onblur={() => (focused = false)} />
</div>

Where class: Still Has an Edge

The directive shines in one specific situation: hyphenated class names. CSS allows hyphens in class names, JavaScript doesn’t allow them in unquoted object keys. The directive handles hyphens naturally:

<!-- Directive: hyphens work without quoting -->
<li class:is-active={isCurrentPage} class:has-children={children.length > 0}>

<!-- Object syntax: hyphens require quoting -->
<li class={{ 'is-active': isCurrentPage, 'has-children': children.length > 0 }}>

For a single toggle with a hyphenated class name, class: is often the cleaner read. For multiple states, or when you need the array syntax’s ability to group classes under one condition, the attribute form wins.


Component Class Prop Merging

One of the most practically important patterns enabled by the clsx-backed attribute is merging a parent’s class customizations with a component’s own internal classes. This is the pattern that makes truly reusable components possible.

Imagine you’re building a Badge component for a design system. Internally, it always applies .badge for baseline structure. But the consumers of that component need to be able to add their own classes — to position it, change its size in context, or apply a custom color scheme. How do you allow that without exposing internals or breaking encapsulation?

The array syntax gives you the answer: put your own class first in the array, and append whatever the parent passes:

<!-- Badge.svelte -->
<script>
	let { label, count, class: extraClass } = $props()
</script>

<span class={['badge', extraClass]}>
	{label}
	{#if count}
		<span class="badge-count">{count}</span>
	{/if}
</span>

<style>
	.badge {
		display: inline-flex;
		align-items: center;
		gap: 0.375rem;
		padding: 0.25rem 0.625rem;
		border-radius: 9999px;
		font-size: 0.75rem;
		font-weight: 500;
		background: #f3f4f6;
		color: #374151;
	}

	.badge-count {
		background: #6b7280;
		color: white;
		border-radius: 9999px;
		padding: 0 0.375rem;
		font-size: 0.625rem;
	}
</style>

The parent now has complete flexibility. They can pass a string, an object, an array — anything the class attribute accepts — and it composes correctly with the component’s base class:

<script>
	import Badge from './Badge.svelte'

	let unreadCount = $state(4)
	let isUrgent = $state(false)
</script>

<!-- Simple string override -->
<Badge label="Inbox" count={unreadCount} class="ml-2" />

<!-- Conditional object from the parent's context -->
<Badge label="Alerts" count={unreadCount} class={{ 'bg-red-100 text-red-700': isUrgent }} />

<!-- Array of classes from the parent -->
<Badge label="New" class={['mt-1', 'border border-blue-300']} />

Without this pattern, you’d have to choose between two bad options: accepting only a string class prop (which rejects objects and arrays), or using :global() workarounds that leak implementation details. The array-based merge gives you a clean, expressive escape hatch that respects both sides.


TypeScript: The ClassValue Type

When you expose a class prop on a component — as in the Badge example above — you need a type for it. The obvious choices are string (too narrow — it rejects objects and arrays that the attribute can handle) and any (too broad — you lose all type safety).

Since Svelte 5.19, Svelte exports a ClassValue type from svelte/elements that represents exactly what the class attribute accepts: strings, objects, arrays, nested arrays and objects, null, undefined, and combinations thereof. Using it gives you accurate type-checking on both the component definition and its call sites:

<!-- SearchInput.svelte -->
<script lang="ts">
	import type { ClassValue } from 'svelte/elements'

	let {
		placeholder = 'Search…',
		value = $bindable(''),
		class: extraClass
	}: {
		placeholder?: string
		value?: string
		class?: ClassValue
	} = $props()
</script>

<div class={['search-wrapper', extraClass]}>
	<span class="search-icon" aria-hidden="true">🔍</span>
	<input type="search" {placeholder} bind:value class="search-input" />
</div>

<style>
	.search-wrapper {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		padding: 0.5rem 0.875rem;
		border: 1px solid #d1d5db;
		border-radius: 8px;
		background: white;
	}

	.search-icon {
		color: #9ca3af;
	}
	.search-input {
		border: none;
		outline: none;
		flex: 1;
		font-size: 0.875rem;
	}
</style>

Now a parent can pass any valid class expression without TypeScript complaining:

<script lang="ts">
	import SearchInput from './SearchInput.svelte'

	let query = $state('')
	let isFiltering = $state(false)
</script>

<!-- All of these are valid under ClassValue -->
<SearchInput bind:value={query} class="w-full" />
<SearchInput bind:value={query} class={{ 'ring-2 ring-blue-500': isFiltering }} />
<SearchInput bind:value={query} class={['mt-4', isFiltering && 'bg-blue-50']} />

Real-World Patterns

Pattern 1: Notification Feed Item

A notification component has several orthogonal states — whether it’s been read, whether it’s pinned, and its category type. The object syntax maps cleanly to this:

<!-- NotificationItem.svelte -->
<script>
	type Category = 'mention' | 'reply' | 'system' | 'billing'

	let {
		title,
		body,
		category,
		isRead = false,
		isPinned = false,
		timestamp,
		onDismiss
	}: {
		title: string
		body: string
		category: Category
		isRead?: boolean
		isPinned?: boolean
		timestamp: Date
		onDismiss: () => void
	} = $props()

	let timeAgo = $derived(formatTimeAgo(timestamp))

	function formatTimeAgo(date: Date): string {
		const diff = Math.floor((Date.now() - date.getTime()) / 60000)
		if (diff < 1) return 'just now'
		if (diff < 60) return `${diff}m ago`
		if (diff < 1440) return `${Math.floor(diff / 60)}h ago`
		return `${Math.floor(diff / 1440)}d ago`
	}
</script>

<div
	class={{
		notification: true,
		'notification-unread': !isRead,
		'notification-pinned': isPinned,
		'notification-mention': category === 'mention',
		'notification-billing': category === 'billing'
	}}
>
	<div class="notification-body">
		<p class="notification-title">{title}</p>
		<p class="notification-text">{body}</p>
		<time class="notification-time">{timeAgo}</time>
	</div>
	<button class="notification-dismiss" onclick={onDismiss} aria-label="Dismiss">×</button>
</div>

<style>
	.notification {
		display: flex;
		align-items: flex-start;
		gap: 0.75rem;
		padding: 1rem;
		border-bottom: 1px solid #f3f4f6;
		background: white;
		transition: background 0.1s ease;
	}

	/* Unread notifications have a distinct left border and lighter background */
	.notification-unread {
		background: #f8faff;
		border-left: 3px solid #3b82f6;
	}

	/* Pinned items sit above the fold with a subtle tint */
	.notification-pinned {
		background: #fffbeb;
		border-left: 3px solid #f59e0b;
	}

	/* A pinned unread overrides: pinned wins on color since it's more prominent */
	.notification-pinned.notification-unread {
		border-left-color: #f59e0b;
	}

	/* Category-specific icon tints applied via CSS custom properties */
	.notification-mention {
		--icon-color: #8b5cf6;
	}
	.notification-billing {
		--icon-color: #ef4444;
	}

	.notification-body {
		flex: 1;
		min-width: 0;
	}
	.notification-title {
		font-weight: 600;
		font-size: 0.875rem;
		color: #111827;
		margin: 0 0 0.25rem;
	}
	.notification-text {
		font-size: 0.8125rem;
		color: #6b7280;
		margin: 0 0 0.375rem;
		line-height: 1.5;
	}
	.notification-time {
		font-size: 0.75rem;
		color: #9ca3af;
	}
	.notification-dismiss {
		flex-shrink: 0;
		background: none;
		border: none;
		cursor: pointer;
		color: #9ca3af;
		font-size: 1.25rem;
		line-height: 1;
		padding: 0.125rem 0.25rem;
	}
	.notification-dismiss:hover {
		color: #374151;
	}
</style>

Notice the .notification-pinned.notification-billing compound selector in the CSS — that’s how you express what happens when two states are active simultaneously. The class binding side never needs to know about that intersection; you just toggle the individual state classes and let CSS handle the combinations.

Pattern 2: File Upload Drop Zone

An upload zone passes through several distinct phases. The array syntax lets you group the right utility classes for each phase while keeping the template readable:

<script>
	type UploadPhase = 'idle' | 'hover' | 'uploading' | 'done' | 'error'

	let phase = $state<UploadPhase>('idle')
	let progress = $state(0)

	function handleDragOver(e: DragEvent) {
		e.preventDefault()
		phase = 'hover'
	}

	function handleDragLeave() {
		if (phase === 'hover') phase = 'idle'
	}

	async function handleDrop(e: DragEvent) {
		e.preventDefault()
		phase = 'uploading'
		// Upload logic would go here; simulate progress
		for (let i = 0; i <= 100; i += 10) {
			await new Promise((r) => setTimeout(r, 100))
			progress = i
		}
		phase = 'done'
	}
</script>

<div
	class={[
		'drop-zone',
		phase === 'idle' && 'drop-idle',
		phase === 'hover' && 'drop-hover drop-active-border',
		phase === 'uploading' && 'drop-uploading drop-busy',
		phase === 'done' && 'drop-done drop-success-border',
		phase === 'error' && 'drop-error drop-error-border'
	]}
	ondragover={handleDragOver}
	ondragleave={handleDragLeave}
	ondrop={handleDrop}
	role="region"
	aria-label="File upload area"
>
	{#if phase === 'idle' || phase === 'hover'}
		<p>Drop files here or <button>browse</button></p>
	{:else if phase === 'uploading'}
		<p>Uploading… {progress}%</p>
	{:else if phase === 'done'}
		<p>Upload complete!</p>
	{:else}
		<p>Upload failed. <button onclick={() => (phase = 'idle')}>Try again</button></p>
	{/if}
</div>

<style>
	.drop-zone {
		border: 2px dashed #d1d5db;
		border-radius: 12px;
		padding: 3rem 2rem;
		text-align: center;
		transition:
			border-color 0.15s ease,
			background 0.15s ease;
	}

	.drop-idle {
		background: #f9fafb;
	}
	.drop-hover {
		background: #eff6ff;
	}
	.drop-active-border {
		border-color: #3b82f6;
		border-style: solid;
	}
	.drop-uploading {
		background: #f0fdf4;
	}
	.drop-busy {
		pointer-events: none;
		cursor: wait;
	}
	.drop-done {
		background: #f0fdf4;
	}
	.drop-success-border {
		border-color: #22c55e;
		border-style: solid;
	}
	.drop-error {
		background: #fef2f2;
	}
	.drop-error-border {
		border-color: #ef4444;
		border-style: solid;
	}
</style>

Each phase activates a group of classes together — the background color class and the border behavior class as a pair. If you later decide that the hover phase should also have cursor-copy styling, you add it to that one line in the array rather than hunting through a ternary chain.

Pattern 3: Active Navigation with SvelteKit

A NavItem component that reads the current route and drives its own active state reactively. The page object from $app/state is reactive — every navigation updates it, and any $derived value that reads from it re-evaluates automatically:

<!-- NavItem.svelte -->
<script>
	import { page } from '$app/state'

	let { href, label, icon }: { href: string; label: string; icon: string } = $props()

	// Exact match for leaf routes; prefix match for section roots
	let isExact = $derived(page.url.pathname === href)
	let isSection = $derived(page.url.pathname.startsWith(href) && href !== '/' && !isExact)
</script>

<a
	{href}
	class={{
		'nav-item': true,
		'nav-item-active': isExact,
		'nav-item-section': isSection
	}}
	aria-current={isExact ? 'page' : undefined}
>
	<span class="nav-icon" aria-hidden="true">{icon}</span>
	<span class="nav-label">{label}</span>
</a>

<style>
	.nav-item {
		display: flex;
		align-items: center;
		gap: 0.625rem;
		padding: 0.5rem 0.875rem;
		border-radius: 6px;
		text-decoration: none;
		font-size: 0.875rem;
		color: #4b5563;
		font-weight: 500;
		transition:
			background 0.1s ease,
			color 0.1s ease;
	}

	.nav-item:hover:not(.nav-item-active) {
		background: #f3f4f6;
		color: #111827;
	}

	/* You're in a child of this section, but not on it directly */
	.nav-item-section {
		color: #3b82f6;
		background: #eff6ff;
	}

	/* This is the exact current page */
	.nav-item-active {
		background: #dbeafe;
		color: #1d4ed8;
		font-weight: 600;
	}

	.nav-icon {
		font-size: 1rem;
	}
	.nav-label {
		flex: 1;
	}
</style>

Pattern 4: Composable Card with Class Prop

A Card component that ships with its own structural styles but lets parents layer on context-specific classes without needing :global() or prop drilling:

<!-- Card.svelte -->
<script lang="ts">
	import type { ClassValue } from 'svelte/elements'

	let {
		children,
		padding = 'md',
		class: extraClass
	}: {
		children: import('svelte').Snippet
		padding?: 'sm' | 'md' | 'lg' | 'none'
		class?: ClassValue
	} = $props()
</script>

<article class={['card', `card-pad-${padding}`, extraClass]}>
	{@render children()}
</article>

<style>
	.card {
		background: white;
		border: 1px solid #e5e7eb;
		border-radius: 12px;
		box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
	}

	.card-pad-none {
		padding: 0;
	}
	.card-pad-sm {
		padding: 0.75rem;
	}
	.card-pad-md {
		padding: 1.5rem;
	}
	.card-pad-lg {
		padding: 2.5rem;
	}
</style>

The parent passes any class shape they need — a string for positioning, an object for conditional states, or an array combining both:

<script>
	import Card from './Card.svelte'
	let isPremium = $state(false)
</script>

<!-- String: simple positional utility -->
<Card class="col-span-2">Wide card</Card>

<!-- Object: conditional appearance from parent context -->
<Card class={{ 'ring-2 ring-amber-400': isPremium }}>
	{#if isPremium}Premium feature{/if}
</Card>

<!-- Array: combining positioning and conditional state -->
<Card class={['mt-6', isPremium && 'shadow-lg shadow-amber-100']}>Mixed card</Card>

When to Use Class Binding vs style:

Both are reactive and both update the DOM based on state — but they serve different jobs. Class binding is for discrete states where the element is in one of a known set of conditions and you’ve defined CSS for each. style: is for continuous values — a progress percentage, a pixel position, an HSL color from a slider — anything that can take any value on a spectrum and can’t be meaningfully enumerated as classes.

<script>
	let volume = $state(75)
	let isMuted = $derived(volume === 0)
</script>

<!-- volume is continuous: it can be any number 0–100 — use style: -->
<!-- isMuted is discrete: it's either true or false — use class -->
<div class={['volume-bar', { muted: isMuted }]} style:width="{volume}%"></div>

<style>
	.volume-bar {
		height: 4px;
		background: #3b82f6;
		border-radius: 9999px;
		transition:
			width 0.1s ease,
			background 0.2s ease;
	}

	.volume-bar.muted {
		background: #9ca3af;
	}
</style>

The practical test is straightforward: if you could write a finite list of CSS classes — one per state — and cover all the cases, that’s a class binding situation. If the value would require an infinite number of classes to cover all possible inputs, that’s style:.


Common Mistakes

Letting Logic Accumulate in the Template

The class expressions accept any JavaScript, which can tempt you to inline complex conditions directly. This works but makes the template harder to understand over time:

<!-- Avoid: conditions that require careful reading to understand -->
<div class={{ blocked: status === 'blocked' && !isAdmin && retryCount >= 3 }}>

Name the condition in $derived instead. The name itself becomes documentation, and the condition has exactly one place to change if the logic evolves:

<script>
	// The name explains *what* this state means, not just *how* to compute it
	let isPermanentlyBlocked = $derived(
		status === 'blocked' && !isAdmin && retryCount >= 3
	)
</script>

<div class={{ blocked: isPermanentlyBlocked }}>

Using style: for Discrete States

<!-- Avoid: a discrete on/off state expressed as an inline style -->
<div style:border-color={hasWarning ? '#f59e0b' : '#e5e7eb'}>

The component has a warning or it doesn’t — that’s a discrete state. Expressing it through style: means the “what does a warning look like” decision is split across the template and the CSS, instead of being entirely in the CSS:

<!-- Preferred: class for the state, all visuals in the stylesheet -->
<div class={{ 'has-warning': hasWarning }}>

<style>
	div { border: 1px solid #e5e7eb; }
	div.has-warning { border-color: #f59e0b; background: #fffbeb; }
</style>

Now adding focus styles, hover states, or animation to the warning appearance is purely a CSS concern.

The Falsy Stringification Trap

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

<!-- Avoid: class="false" is literally set on the element -->
<tr class={isHighlighted ? 'highlighted' : false}>

<!-- Preferred: null properly omits the attribute -->
<tr class={isHighlighted ? 'highlighted' : null}>

<!-- Better still: the object form handles this correctly by design -->
<tr class={{ highlighted: isHighlighted }}>

Quick Reference

Primitive Ternary

<div class={isLarge ? 'size-lg' : 'size-sm'}>...</div>

Object Syntax

<div class={{ 'item-active': isActive, 'item-disabled': isDisabled, loading }}>...</div>

Array Syntax (Multiple Classes Per Condition)

<div class={[isPlaying && 'track-active track-highlight', isLiked && 'track-liked']}>...</div>

Static Base + Dynamic States

<div class="card" class={{ featured: isFeatured, 'card-compact': isCompact }}>...</div>

Component Class Merging

<!-- Inside the component -->
<button class={['btn', props.class]}>...</button>

class: Directive

<div class:is-open={panelOpen} class:disabled>...</div>

TypeScript: ClassValue

<script lang="ts">
	import type { ClassValue } from 'svelte/elements'

	let { class: extraClass }: { class?: ClassValue } = $props()
</script>

<div class={['base-component', extraClass]}>...</div>

What’s Next?

With class binding covered, you have the full toolkit for controlling how components look based on state. The rest of the styling topic builds on this foundation: