Components That Work as a System

Some UI patterns require multiple components that work together as a unified system. A tabs interface needs a container that tracks the active tab, trigger buttons that switch tabs, and content panels that show or hide based on selection. These related components must communicate internally while presenting a clean API to developers using them.

This pattern is called compound components. The components are designed to work together, sharing state through context rather than props. From the outside, developers compose them flexibly. On the inside, context coordinates their behavior.

Consider how you might want to use a tabs component:

<Tabs>
	<TabList>
		<Tab id="overview">Overview</Tab>
		<Tab id="features">Features</Tab>
		<Tab id="pricing">Pricing</Tab>
	</TabList>

	<TabPanel id="overview">
		<p>Welcome to our product!</p>
	</TabPanel>

	<TabPanel id="features">
		<p>Here are the amazing features...</p>
	</TabPanel>

	<TabPanel id="pricing">
		<p>Choose a plan that works for you.</p>
	</TabPanel>
</Tabs>

This declarative API is intuitive. Developers don’t need to wire up state management or pass callbacks between components. They simply compose the pieces, and context handles the coordination.

In this article, you’ll build two compound component systems: a fully accessible tabs interface and an accordion. You’ll learn the patterns that make compound components work and how to apply them to your own designs.


Understanding Compound Components

Before building, let’s understand what makes compound components different from regular components.

The Problem They Solve

Imagine building a tabs component as a single monolithic component:

<!-- Monolithic approach - limited flexibility -->
<Tabs
	tabs={[
		{ id: 'a', label: 'Tab A', content: 'Content for A' },
		{ id: 'b', label: 'Tab B', content: 'Content for B' }
	]}
/>

This works for simple cases, but quickly hits limitations:

  • What if tab content needs to be complex components, not just strings?
  • What if you want icons in tab labels?
  • What if you need the tab list and panels in different DOM locations?
  • What if you want to conditionally render certain tabs?

The monolithic approach forces everything through props, making customization awkward.

The Compound Component Solution

Compound components flip this around. Instead of one component doing everything, you have multiple components that each handle one concern:

ComponentResponsibility
TabsContainer that provides context and manages state
TabListWrapper for tab triggers with proper ARIA role
TabIndividual tab trigger button
TabPanelContent panel that shows when its tab is active

Each component is simple. Together, they create a flexible system.

How Context Enables This

The magic is context. The Tabs container creates a context with:

  • The currently active tab ID
  • A function to change the active tab
  • A way for tabs and panels to register themselves

Child components consume this context to:

  • Know if they should be active (panels)
  • Handle click events to switch tabs (tab triggers)
  • Register themselves so the system knows what tabs exist

No props need to flow through intermediate components. A TabPanel deep in the tree can directly access the tabs context.


Building the Tabs System

Let’s build a complete, accessible tabs implementation. here’s an overview of the components:

ComponentResponsibility
ContextShared state and methods for tab coordination
TabsContainer that provides context and manages state
TabListWrapper for tab triggers with proper ARIA role
TabIndividual tab trigger button
TabPanelContent panel that shows when its tab is active

Tabs Context

First, define the context that coordinates all tab components:

// src/lib/components/tabs/tabs-context.svelte.ts

import { getContext, hasContext, setContext } from 'svelte'
// Use SvelteKit's `dev` flag to emit helpful warnings during development.
import { dev } from '$app/environment'

/**
 * Symbol key ensures no collisions with other contexts.
 */
const TABS_KEY = Symbol('tabs')

/**
 * The shape of the tabs context available to child components.
 */
export interface TabsContext {
	/** The ID of the currently active tab */
	readonly activeTabId: string

	/** Orientation affects keyboard navigation */
	readonly orientation: 'horizontal' | 'vertical'

	/** Register a tab (called when Tab mounts) */
	registerTab(id: string): void

	/** Unregister a tab (called when Tab unmounts) */
	unregisterTab(id: string): void

	/** Change the active tab */
	setActiveTab(id: string): void

	/** Check if a specific tab is active */
	isActive(id: string): boolean

	/** Get all registered tab IDs in order */
	readonly tabIds: readonly string[]
}

/**
 * Options for creating the tabs context.
 */
export interface TabsOptions {
	/** Initially active tab ID */
	defaultTab?: string

	/** Tab orientation for styling and keyboard nav */
	orientation?: 'horizontal' | 'vertical'

	/** Callback when active tab changes */
	onTabChange?: (tabId: string) => void
}

/**
 * Creates and provides the tabs context.
 * Call this in the Tabs container component.
 */
export function createTabsContext(options: TabsOptions = {}): TabsContext {
	const { defaultTab, onTabChange } = options

	// Orientation derives from the parent option; no local setter to avoid effect loops
	const orientation = $derived(options.orientation ?? 'horizontal')

	// Track registered tabs in order
	let tabIds = $state<string[]>([])

	// Currently active tab
	let activeTabId = $state(defaultTab ?? '')

	// Initial active tab will be set on first register if none is selected

	const context: TabsContext = {
		get activeTabId() {
			return activeTabId
		},

		get orientation() {
			return orientation
		},

		get tabIds() {
			return tabIds
		},

		registerTab(id: string) {
			// Dev-only guard: avoid registering duplicate tab ids (helps catch mistakes)
			if (tabIds.includes(id)) {
				if (dev) console.warn(`[Tabs] Duplicate tab id "${id}" ignored.`)
				return
			}
			// Add to the end, preserving DOM order
			tabIds = [...tabIds, id]
			// If no active tab yet, select the first registered
			if (!activeTabId) {
				activeTabId = id
			}
		},

		unregisterTab(id: string) {
			tabIds = tabIds.filter((tabId) => tabId !== id)

			// If the active tab was removed, select the first available
			if (activeTabId === id && tabIds.length > 0) {
				activeTabId = tabIds[0]
			}
		},

		setActiveTab(id: string) {
			if (tabIds.includes(id) && id !== activeTabId) {
				activeTabId = id
				onTabChange?.(id)
			}
		},

		isActive(id: string) {
			return activeTabId === id
		}

		// orientation is derived from parent option; no local update method
	}

	return setContext(TABS_KEY, context)
}

/**
 * Retrieves the tabs context.
 * Must be called from a component inside Tabs.
 */
export function getTabsContext(): TabsContext {
	if (!hasContext(TABS_KEY)) {
		throw new Error(
			'Tabs context not found. ' + 'Ensure this component is inside a <Tabs> container.'
		)
	}
	return getContext(TABS_KEY)
}

This context provides all the shared state and methods needed for tab coordination.

The Tabs Container

Next, create the main Tabs component that provides the context.

The Tabs component accepts these props:

PropDescription
defaultTabID of the initially active tab
orientationTab orientation
onTabChangeCallback -fn- when active tab changes
classAdditional CSS class
childrenChild content
<!-- src/lib/components/tabs/Tabs.svelte -->
<script lang="ts">
	import type { Snippet } from 'svelte'
	import { createTabsContext } from './tabs-context.svelte'

	interface Props {
		/** ID of the initially active tab */
		defaultTab?: string

		/** Tab orientation */
		orientation?: 'horizontal' | 'vertical'

		/** Callback when active tab changes */
		onTabChange?: (tabId: string) => void

		/** Additional CSS class */
		class?: string

		/** Child content */
		children: Snippet
	}

	let {
		defaultTab,
		orientation = 'horizontal',
		onTabChange,
		class: className = '',
		children
	}: Props = $props()

	// Create and provide the context synchronously so children
	// can access it during initial render (important for SSR)
	createTabsContext({
		get defaultTab() {
			return defaultTab
		},
		get orientation() {
			return orientation
		},
		onTabChange: (tabId: string) => onTabChange?.(tabId)
	})
</script>

<div class="tabs tabs-{orientation} {className}" data-orientation={orientation}>
	{@render children()}
</div>

<style>
	.tabs {
		display: flex;
		flex-direction: column;
	}

	.tabs-vertical {
		flex-direction: row;
	}
</style>

The Tab List

Now create the TabList component that wraps the tab triggers.

The TabList component accepts these props:

PropDescription
classadditional css class
childrentab trigger buttons
<!-- src/lib/components/tabs/TabList.svelte -->
<script lang="ts">
	import { getTabsContext } from './tabs-context.svelte'
	import type { Snippet } from 'svelte'

	interface Props {
		/** Additional CSS class */
		class?: string

		/** Tab trigger buttons */
		children: Snippet
	}

	let { class: className = '', children }: Props = $props()

	const tabs = getTabsContext()

	/**
	 * Handles keyboard navigation within the tab list.
	 * Arrow keys move focus between tabs.
	 * Home/End jump to first/last tab.
	 */
	function handleKeyDown(event: KeyboardEvent) {
		const currentIndex = tabs.tabIds.indexOf(tabs.activeTabId)
		if (currentIndex === -1) return

		let newIndex: number | null = null

		// Determine which keys to use based on orientation
		const isHorizontal = tabs.orientation === 'horizontal'
		const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp'
		const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown'

		switch (event.key) {
			case prevKey:
				// Move to previous tab, wrap to end
				newIndex = currentIndex > 0 ? currentIndex - 1 : tabs.tabIds.length - 1
				break

			case nextKey:
				// Move to next tab, wrap to start
				newIndex = currentIndex < tabs.tabIds.length - 1 ? currentIndex + 1 : 0
				break

			case 'Home':
				newIndex = 0
				break

			case 'End':
				newIndex = tabs.tabIds.length - 1
				break

			default:
				return // Don't prevent default for other keys
		}

		if (newIndex !== null) {
			event.preventDefault()
			const newTabId = tabs.tabIds[newIndex]
			tabs.setActiveTab(newTabId)

			// Focus the newly active tab button
			const tabButton = document.getElementById(`tab-${newTabId}`)
			tabButton?.focus()
		}
	}
</script>

<div
	class="tab-list {className}"
	role="tablist"
	aria-orientation={tabs.orientation}
	onkeydown={handleKeyDown}
	tabindex="-1"
>
	{@render children()}
</div>

<style>
	.tab-list {
		display: flex;
		gap: 0.25rem;
		border-bottom: 1px solid var(--color-border, #e2e8f0);
	}

	:global([data-orientation='vertical']) .tab-list {
		flex-direction: column;
		border-bottom: none;
		border-right: 1px solid var(--color-border, #e2e8f0);
	}
</style>

The Tab Trigger

next componet is the individual tab button that users click to switch tabs.

The Tab component accepts these props:

PropDescription
idunique identifier for this tab
disabledwhether this tab is disabled
classadditional css class
childrentab label content
<!-- src/lib/components/tabs/Tab.svelte -->
<script lang="ts">
	import { onMount, type Snippet } from 'svelte'
	import { getTabsContext } from './tabs-context.svelte'

	interface Props {
		/** Unique identifier for this tab */
		id: string

		/** Whether this tab is disabled */
		disabled?: boolean

		/** Additional CSS class */
		class?: string

		/** Tab label content */
		children: Snippet
	}

	let { id, disabled = false, class: className = '', children }: Props = $props()

	const tabs = getTabsContext()

	// Track previous id to support controlled re-registration
	let prevId: string | null = null

	// Register this tab on mount, unregister on unmount
	onMount(() => {
		tabs.registerTab(id)
		prevId = id

		return () => {
			// Unregister the latest known id
			tabs.unregisterTab(prevId ?? id)
		}
	})

	// If `id` changes at runtime, re-register under the new id
	// This is useful when:
	// 1. Localization: tab id mirrors a translated slug and can change with language.
	// 2. Data-driven tabs: API results rename or replace identifiers at runtime.
	// 3. Reordering/virtualization: recycled components get new ids as lists change.
	// 4. A/B variants: switching tab definitions without remounting the container.

	$effect(() => {
		if (prevId !== null && id !== prevId) {
			// Guard against id collisions: if new id already exists, keep prevId
			if (tabs.tabIds.includes(id)) {
				console.warn(`[Tabs] Duplicate tab id detected: "${id}". Keeping previous id: "${prevId}"`)
				return
			}
			const wasActive = tabs.activeTabId === prevId
			tabs.unregisterTab(prevId)
			tabs.registerTab(id)
			if (wasActive) {
				tabs.setActiveTab(id)
			}
			prevId = id
		}
	})

	// Reactive check for active state
	let isActive = $derived(tabs.isActive(id))

	/**
	 * Handle tab selection on click.
	 */
	function handleClick() {
		if (!disabled) {
			tabs.setActiveTab(id)
		}
	}
</script>

<button
	type="button"
	id="tab-{id}"
	class="tab {className}"
	class:active={isActive}
	role="tab"
	aria-selected={isActive}
	aria-controls="tabpanel-{id}"
	tabindex={isActive ? 0 : -1}
	{disabled}
	onclick={handleClick}
>
	{@render children()}
</button>

<style>
	.tab {
		padding: 0.75rem 1rem;
		font-size: 0.9375rem;
		font-weight: 500;
		color: var(--color-foreground-muted, #64748b);
		background: transparent;
		border: none;
		border-bottom: 2px solid transparent;
		margin-bottom: -1px;
		cursor: pointer;
		transition:
			color 0.2s,
			border-color 0.2s;
		white-space: nowrap;
	}

	.tab:hover:not(:disabled) {
		color: var(--color-foreground, #1e293b);
	}

	.tab:focus-visible {
		outline: 2px solid var(--color-primary, #3b82f6);
		outline-offset: -2px;
		border-radius: 4px 4px 0 0;
	}

	.tab.active {
		color: var(--color-primary, #3b82f6);
		border-bottom-color: var(--color-primary, #3b82f6);
	}

	.tab:disabled {
		opacity: 0.5;
		cursor: not-allowed;
	}

	/* Vertical orientation styles */
	:global([data-orientation='vertical']) .tab {
		border-bottom: none;
		border-right: 2px solid transparent;
		margin-bottom: 0;
		margin-right: -1px;
		text-align: left;
	}

	:global([data-orientation='vertical']) .tab.active {
		border-right-color: var(--color-primary, #3b82f6);
	}

	:global([data-orientation='vertical']) .tab:focus-visible {
		border-radius: 4px 0 0 4px;
	}
</style>

The TabPanel

Another key component is the content panel that shows when its tab is active.

The TabPanel component accepts these props:

PropDescription
idID matching the associated Tab
classadditional css class
childrenpanel content
<!-- src/lib/components/tabs/TabPanel.svelte -->
<script lang="ts">
	import type { Snippet } from 'svelte'
	import { getTabsContext } from './tabs-context.svelte'

	interface Props {
		/** ID matching the associated Tab */
		id: string

		/** Additional CSS class */
		class?: string

		/** Panel content */
		children: Snippet
	}

	let { id, class: className = '', children }: Props = $props()

	const tabs = getTabsContext()

	// Only render when this panel's tab is active
	let isActive = $derived(tabs.activeTabId === id)
</script>

{#if isActive}
	<div
		id="tabpanel-{id}"
		class="tab-panel {className}"
		role="tabpanel"
		aria-labelledby="tab-{id}"
		tabindex="0"
	>
		{@render children()}
	</div>
{/if}

<style>
	.tab-panel {
		padding: 1.5rem 0;
	}

	.tab-panel:focus-visible {
		outline: 2px solid var(--color-primary, #3b82f6);
		outline-offset: 2px;
		border-radius: 4px;
	}
</style>

Exporting the Components

Finally, export all components and the context from a single entry point to provide a clean, easy-to-use public API for importing and using the tabs system:

// src/lib/components/tabs/index.ts

export { default as Tabs } from './Tabs.svelte'
export { default as TabList } from './TabList.svelte'
export { default as Tab } from './Tab.svelte'
export { default as TabPanel } from './TabPanel.svelte'

export { getTabsContext } from './tabs-context.svelte'
export type { TabsContext, TabsOptions } from './tabs-context.svelte'

Using the Tabs

Now developers can compose tabs naturally using the exported components and their features as in this example.

<script>
	import { Tabs, TabList, Tab, TabPanel } from '$lib/components/tabs'

	function handleTabChange(tabId: string) {
		console.log('Active tab:', tabId)
	}
</script>

<Tabs defaultTab="features" onTabChange={handleTabChange}>
	<TabList>
		<Tab id="overview">Overview</Tab>
		<Tab id="features">Features</Tab>
		<Tab id="pricing">Pricing</Tab>
		<Tab id="faq" disabled>FAQ (Coming Soon)</Tab>
	</TabList>

	<TabPanel id="overview">
		<h3>Product Overview</h3>
		<p>Welcome to our amazing product. Here's what you need to know...</p>
	</TabPanel>

	<TabPanel id="features">
		<h3>Key Features</h3>
		<ul>
			<li>Feature one with detailed explanation</li>
			<li>Feature two that makes life easier</li>
			<li>Feature three for power users</li>
		</ul>
	</TabPanel>

	<TabPanel id="pricing">
		<h3>Pricing Plans</h3>
		<p>Choose the plan that fits your needs.</p>
	</TabPanel>

	<TabPanel id="faq">
		<h3>Frequently Asked Questions</h3>
		<p>Coming soon!</p>
	</TabPanel>
</Tabs>

Building an Accordion

The accordion follows the same compound component pattern. Multiple sections can expand or collapse, with optional single-item-open behavior.

ComponentResponsibility
ContextShared state and methods for accordion control
AccordionContainer that provides context and manages state
AccordionItemIndividual expandable/collapsible section
AccordionTriggerButton to toggle expansion of an item
AccordionContentContent area shown when item is expanded

Accordion Context

first, define the accordion context to manage expanded state.

// src/lib/components/accordion/accordion-context.svelte.ts

import { getContext, hasContext, setContext } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
import type { AccordionContext, AccordionOptions } from './types'

const ACCORDION_KEY = Symbol('accordion')

/**
 * Creates and provides the accordion context.
 */
export function createAccordionContext(options: AccordionOptions = {}): AccordionContext {
	const { defaultExpanded = [] } = options
	let { single = false, onExpandedChange } = options

	// Track which items are expanded (keep a single reactive instance)
	const expandedIds = new SvelteSet<string>(defaultExpanded)

	/**
	 * Notifies the callback when expanded items change.
	 */
	function notifyChange() {
		onExpandedChange?.([...expandedIds])
	}

	const context: AccordionContext = {
		get expandedIds() {
			return expandedIds
		},

		get single() {
			return single
		},

		toggle(id: string) {
			if (expandedIds.has(id)) {
				this.collapse(id)
			} else {
				this.expand(id)
			}
		},

		isExpanded(id: string) {
			return expandedIds.has(id)
		},

		expand(id: string) {
			if (single) {
				// In single mode, close others when opening one
				expandedIds.clear()
				expandedIds.add(id)
			} else {
				expandedIds.add(id)
			}
			notifyChange()
		},

		collapse(id: string) {
			expandedIds.delete(id)
			notifyChange()
		},

		collapseAll() {
			expandedIds.clear()
			notifyChange()
		},

		setSingle(value: boolean) {
			single = value
		},

		setOnExpandedChange(cb: ((expandedIds: string[]) => void) | undefined) {
			onExpandedChange = cb
		}
	}

	return setContext(ACCORDION_KEY, context)
}

/**
 * Retrieves the accordion context.
 */
export function getAccordionContext(): AccordionContext {
	if (!hasContext(ACCORDION_KEY)) {
		throw new Error(
			'Accordion context not found. ' + 'Ensure this component is inside an <Accordion> container.'
		)
	}
	return getContext(ACCORDION_KEY)
}

Accordion Types

// src/lib/components/accordion/types.ts
// The shape of the accordion context.

/**
 * Options for creating the accordion context.
 */
export interface AccordionOptions {
	single?: boolean
	defaultExpanded?: string[]
	onExpandedChange?: (expandedIds: string[]) => void
}

/**
 * The shape of the accordion context.
 */
export interface AccordionContext {
	/** Set of currently expanded item IDs */
	readonly expandedIds: ReadonlySet<string>

	/** Whether only one item can be open at a time */
	readonly single: boolean

	/** Toggle an item's expanded state */
	toggle(id: string): void

	/** Check if an item is expanded */
	isExpanded(id: string): boolean

	/** Expand a specific item */
	expand(id: string): void

	/** Collapse a specific item */
	collapse(id: string): void

	/** Collapse all items */
	collapseAll(): void

	/** Update whether only one item can be open */
	setSingle(value: boolean): void

	/** Update expanded-change callback */
	setOnExpandedChange(cb: ((expandedIds: string[]) => void) | undefined): void
}

/**
 * Options for creating the accordion context.
 */
export interface AccordionOptions {
	/** Allow only one item open at a time */
	single?: boolean

	/** Initially expanded item IDs */
	defaultExpanded?: string[]

	/** Callback when expanded items change */
	onExpandedChange?: (expandedIds: string[]) => void
}

The Accordion Container

This component provides the accordion context and manages overall behavior.

PropDescription
singleOnly allow one item open at a time
defaultExpandedInitially expanded item IDs
onExpandedChangeCallback -fn- when expanded items change
classAdditional CSS class
childrenChild content
<!-- src/lib/components/accordion/Accordion.svelte -->
<script lang="ts">
	import type { Snippet } from 'svelte'
	import { createAccordionContext } from './accordion-context.svelte'

	interface Props {
		/** Only allow one item open at a time */
		single?: boolean

		/** Initially expanded item IDs */
		defaultExpanded?: string[]

		/** Callback when expanded items change */
		onExpandedChange?: (expandedIds: string[]) => void

		/** Additional CSS class */
		class?: string

		/** Child content */
		children: Snippet
	}

	let {
		single = false,
		defaultExpanded = [],
		onExpandedChange,
		class: className = '',
		children
	}: Props = $props()

	// Keep a reference to the context so we can react to prop changes
	const accordion = createAccordionContext({})

	// Enforce single-mode dynamically if `single` changes
	$effect(() => {
		// keep the change callback up to date
		accordion.setOnExpandedChange(onExpandedChange)
		// keep context's single in sync
		accordion.setSingle(single)
		if (single) {
			// If more than one is open, collapse extras
			let keep: string | undefined
			for (const id of accordion.expandedIds) {
				keep = id
				break
			}
			for (const id of accordion.expandedIds) {
				if (keep && id !== keep) {
					accordion.collapse(id)
				}
			}
		}
	})

	// Reflect `defaultExpanded` updates by resetting expanded items
	$effect(() => {
		accordion.collapseAll()
		for (const id of defaultExpanded) {
			accordion.expand(id)
		}
	})
</script>

<div class="accordion {className}">
	{@render children()}
</div>

<style>
	.accordion {
		display: flex;
		flex-direction: column;
		border: 1px solid var(--color-border, #e2e8f0);
		border-radius: 8px;
		overflow: hidden;
	}
</style>

The Accordion Item

This component represents a single expandable/collapsible section within the accordion.

PropDescription
idUnique identifier for this item
classadditional css class
childrenItem content (should contain AccordionTrigger and AccordionContent)
<!-- src/lib/components/accordion/AccordionItem.svelte -->
<script lang="ts">
	import { getAccordionContext } from './accordion-context.svelte'
	import type { Snippet } from 'svelte'

	interface Props {
		/** Unique identifier for this item */
		id: string

		/** Additional CSS class */
		class?: string

		/** Item content (should contain AccordionTrigger and AccordionContent) */
		children: Snippet
	}

	let { id, class: className = '', children }: Props = $props()

	const accordion = getAccordionContext()

	let isExpanded = $derived(accordion.isExpanded(id))
</script>

<div
	class="accordion-item {className}"
	class:expanded={isExpanded}
	data-state={isExpanded ? 'open' : 'closed'}
>
	{@render children()}
</div>

<style>
	.accordion-item {
		border-bottom: 1px solid var(--color-border, #e2e8f0);
	}

	.accordion-item:last-child {
		border-bottom: none;
	}
</style>

The Accordion Trigger

This component acts as the button to toggle the expansion of an accordion item.

PropDescription
itemIdID of the accordion item
classadditional css class
childrentrigger label content
<!-- src/lib/components/accordion/AccordionTrigger.svelte -->
<script lang="ts">
	import { getAccordionContext } from './accordion-context.svelte'
	import type { Snippet } from 'svelte'

	interface Props {
		/** ID of the accordion item this trigger controls */
		itemId: string

		/** Additional CSS class */
		class?: string

		/** Trigger label content */
		children: Snippet
	}

	let { itemId, class: className = '', children }: Props = $props()

	const accordion = getAccordionContext()

	let isExpanded = $derived(accordion.isExpanded(itemId))

	function handleClick() {
		accordion.toggle(itemId)
	}

	function handleKeyDown(event: KeyboardEvent) {
		if (event.key === 'Enter' || event.key === ' ') {
			event.preventDefault()
			accordion.toggle(itemId)
		}
	}
</script>

<button
	type="button"
	id="accordion-trigger-{itemId}"
	class="accordion-trigger {className}"
	aria-expanded={isExpanded}
	aria-controls="accordion-content-{itemId}"
	onclick={handleClick}
	onkeydown={handleKeyDown}
>
	<span class="trigger-text">
		{@render children()}
	</span>

	<svg
		class="trigger-icon"
		class:rotated={isExpanded}
		viewBox="0 0 24 24"
		fill="none"
		stroke="currentColor"
		stroke-width="2"
		aria-hidden="true"
	>
		<polyline points="6 9 12 15 18 9" />
	</svg>
</button>

<style>
	.accordion-trigger {
		display: flex;
		align-items: center;
		justify-content: space-between;
		width: 100%;
		padding: 1rem 1.25rem;
		font-size: 0.9375rem;
		font-weight: 500;
		text-align: left;
		color: var(--color-foreground, #1e293b);
		background: transparent;
		border: none;
		cursor: pointer;
		transition: background-color 0.2s;
	}

	.accordion-trigger:hover {
		background-color: var(--color-surface, #f8fafc);
	}

	.accordion-trigger:focus-visible {
		outline: 2px solid var(--color-primary, #3b82f6);
		outline-offset: -2px;
	}

	.trigger-text {
		flex: 1;
	}

	.trigger-icon {
		width: 20px;
		height: 20px;
		color: var(--color-foreground-muted, #64748b);
		transition: transform 0.2s ease;
		flex-shrink: 0;
	}

	.trigger-icon.rotated {
		transform: rotate(180deg);
	}
</style>

The Accordion Content

This component displays the content for each accordion item when expanded.

PropDescription
itemIdID of the accordion item
classadditional css class
childrencontent to show when expanded
<!-- src/lib/components/accordion/AccordionContent.svelte -->
<script lang="ts">
	import { getAccordionContext } from './accordion-context.svelte'
	import type { Snippet } from 'svelte'

	interface Props {
		/** ID of the accordion item this content belongs to */
		itemId: string

		/** Additional CSS class */
		class?: string

		/** Content to show when expanded */
		children: Snippet
	}

	let { itemId, class: className = '', children }: Props = $props()

	const accordion = getAccordionContext()

	let isExpanded = $derived(accordion.isExpanded(itemId))
</script>

<div
	id="accordion-content-{itemId}"
	class="accordion-content {className}"
	class:expanded={isExpanded}
	role="region"
	aria-labelledby="accordion-trigger-{itemId}"
	hidden={!isExpanded}
>
	<div class="content-inner">
		{@render children()}
	</div>
</div>

<style>
	.accordion-content {
		display: grid;
		grid-template-rows: 0fr;
		transition: grid-template-rows 0.2s ease;
	}

	.accordion-content.expanded {
		grid-template-rows: 1fr;
	}

	.content-inner {
		overflow: hidden;
	}

	.accordion-content.expanded .content-inner {
		padding: 0 1.25rem 1rem;
	}
</style>

Exporting the Components

Finally, export all accordion components and the context from a single entry point to provide a clean, easy-to-use public API.

// src/lib/components/accordion/index.ts

export { default as Accordion } from './Accordion.svelte'
export { default as AccordionContent } from './AccordionContent.svelte'
export { default as AccordionItem } from './AccordionItem.svelte'
export { default as AccordionTrigger } from './AccordionTrigger.svelte'

export { getAccordionContext } from './accordion-context.svelte'

Using the Accordion

<script>
	import {
		Accordion,
		AccordionItem,
		AccordionTrigger,
		AccordionContent
	} from '$lib/components/accordion'
</script>

<!-- Single mode: only one item open at a time -->
<Accordion single defaultExpanded={['item-1']}>
	<AccordionItem id="item-1">
		<AccordionTrigger itemId="item-1">What is your return policy?</AccordionTrigger>
		<AccordionContent itemId="item-1">
			<p>
				We offer a 30-day return policy for all unused items in their original packaging. Simply
				contact our support team to initiate a return.
			</p>
		</AccordionContent>
	</AccordionItem>

	<AccordionItem id="item-2">
		<AccordionTrigger itemId="item-2">How long does shipping take?</AccordionTrigger>
		<AccordionContent itemId="item-2">
			<p>
				Standard shipping takes 5-7 business days. Express shipping is available for 2-3 business
				day delivery.
			</p>
		</AccordionContent>
	</AccordionItem>

	<AccordionItem id="item-3">
		<AccordionTrigger itemId="item-3">Do you ship internationally?</AccordionTrigger>
		<AccordionContent itemId="item-3">
			<p>
				Yes! We ship to over 50 countries. International shipping typically takes 10-14 business
				days.
			</p>
		</AccordionContent>
	</AccordionItem>
</Accordion>

Building a Toast Notification System

The tabs and accordion follow similar patterns: static children declared in markup that register themselves on mount. A toast system introduces fundamentally different concepts—components created dynamically at runtime through an imperative API.

What Makes Toast Different

Unlike tabs where you write <Tab id="x"> in your template, toasts are created programmatically:

<script>
	import { getToastContext } from '$lib/components/toast'

	const toast = getToastContext()

	function handleSave() {
		// After some async operation
		toast.success('Document saved!')
	}
</script>

This requires different context patterns:

  • Imperative creation — Functions that add new items to state
  • Auto-dismiss timers — Scheduled cleanup after a duration
  • Dynamic rendering — Iterating over a reactive collection
  • Fixed-position viewport — Toasts appear outside the normal document flow
  • Timer lifecycle management — Proper cleanup to prevent memory leaks

Project Structure

The toast system consists of six files:

src/lib/components/toast/
├── index.ts                    # Public exports
├── types.ts                    # TypeScript interfaces
├── toast-context.svelte.ts     # Context and state management
├── ToastProvider.svelte        # Provider component
├── ToastViewport.svelte        # Renders toast collection
└── ToastItem.svelte            # Individual toast component

Types Module

First, define the types in a dedicated module:

// src/lib/components/toast/types.ts

/**
 * Visual style variants for toast notifications.
 */
export type ToastType = 'info' | 'success' | 'warning' | 'error'

/**
 * Position options for the toast viewport.
 */
export type ToastPosition =
	| 'top-right'
	| 'top-left'
	| 'top-center'
	| 'bottom-right'
	| 'bottom-left'
	| 'bottom-center'

/**
 * A single toast notification instance.
 */
export interface Toast {
	readonly id: string
	readonly message: string
	readonly type: ToastType
	readonly duration: number
	readonly createdAt: number
}

/**
 * Options for creating a new toast.
 */
export interface ToastOptions {
	message: string
	type?: ToastType
	duration?: number
}

/**
 * The public API exposed by the toast context.
 */
export interface ToastContext {
	readonly toasts: readonly Toast[]
	show(options: ToastOptions): string
	dismiss(id: string): void
	dismissAll(): void
	info(message: string, duration?: number): string
	success(message: string, duration?: number): string
	warning(message: string, duration?: number): string
	error(message: string, duration?: number): string
}

/**
 * Configuration for the ToastProvider.
 */
export interface ToastProviderConfig {
	defaultDuration?: number
	maxToasts?: number
	position?: ToastPosition
}

Toast Context

The context manages toast state, timers, and provides the imperative API. Using a class encapsulates the timer Map and ensures proper cleanup:

// src/lib/components/toast/toast-context.svelte.ts

import { setContext, getContext, hasContext } from 'svelte'
import type {
	Toast,
	ToastType,
	ToastOptions,
	ToastContext,
	ToastPosition,
	ToastProviderConfig
} from './types.js'

// Re-export types for consumer convenience
export type { Toast, ToastType, ToastOptions, ToastContext, ToastPosition, ToastProviderConfig }

/**
 * Symbol keys for context isolation.
 */
const TOAST_CONTEXT_KEY = Symbol('toast-context')
const TOAST_POSITION_KEY = Symbol('toast-position')

/**
 * Generates a unique ID for each toast.
 */
function generateToastId(): string {
	const timestamp = Date.now().toString(36)
	const random = Math.random().toString(36).substring(2, 9)
	return `toast-${timestamp}-${random}`
}

/**
 * Internal class that manages toast state and timers.
 * Encapsulating in a class ensures the timer Map is properly
 * scoped and avoids closure issues with $state.
 */
class ToastStateManager {
	/** Reactive state for the toasts array */
	private _toasts = $state<Toast[]>([])

	/** Non-reactive Map for tracking auto-dismiss timers */
	private timerRegistry: Map<string, ReturnType<typeof setTimeout>>

	/** Configuration values */
	private readonly defaultDuration: number
	private readonly maxToasts: number

	constructor(defaultDuration: number, maxToasts: number) {
		this.defaultDuration = defaultDuration
		this.maxToasts = maxToasts
		this.timerRegistry = new Map()
	}

	/** Read-only access to toasts */
	get toasts(): readonly Toast[] {
		return this._toasts
	}

	/** Create and show a new toast */
	show(options: ToastOptions): string {
		const toast: Toast = {
			id: generateToastId(),
			message: options.message,
			type: options.type ?? 'info',
			duration: options.duration ?? this.defaultDuration,
			createdAt: Date.now()
		}

		// Add new toast, enforce maxToasts by keeping newest
		this._toasts = [...this._toasts, toast].slice(-this.maxToasts)

		// Schedule auto-dismiss if duration > 0
		if (toast.duration > 0) {
			const timerId = setTimeout(() => {
				this.dismiss(toast.id)
			}, toast.duration)
			this.timerRegistry.set(toast.id, timerId)
		}

		return toast.id
	}

	/** Dismiss a specific toast by ID */
	dismiss(id: string): void {
		// Clear timer first to prevent race conditions
		const timerId = this.timerRegistry.get(id)
		if (timerId !== undefined) {
			clearTimeout(timerId)
			this.timerRegistry.delete(id)
		}

		// Remove from reactive state
		this._toasts = this._toasts.filter((t) => t.id !== id)
	}

	/** Dismiss all toasts and clear all timers */
	dismissAll(): void {
		// Clear all pending timers
		for (const timerId of this.timerRegistry.values()) {
			clearTimeout(timerId)
		}
		this.timerRegistry.clear()

		// Clear toasts array
		this._toasts = []
	}

	/** Cleanup method for when provider unmounts */
	destroy(): void {
		this.dismissAll()
	}
}

/**
 * Creates and provides the toast context.
 * Call this in ToastProvider during component initialization.
 */
export function createToastContext(
	defaultDuration: number,
	maxToasts: number,
	position: ToastPosition
): ToastContext {
	const manager = new ToastStateManager(defaultDuration, maxToasts)

	// Set up cleanup when provider unmounts
	$effect(() => {
		return () => manager.destroy()
	})

	const context: ToastContext = {
		get toasts() {
			return manager.toasts
		},

		show(options: ToastOptions): string {
			return manager.show(options)
		},

		dismiss(id: string): void {
			manager.dismiss(id)
		},

		dismissAll(): void {
			manager.dismissAll()
		},

		// Convenience methods
		info(message: string, duration?: number): string {
			return manager.show({ message, type: 'info', duration })
		},

		success(message: string, duration?: number): string {
			return manager.show({ message, type: 'success', duration })
		},

		warning(message: string, duration?: number): string {
			return manager.show({ message, type: 'warning', duration })
		},

		error(message: string, duration?: number): string {
			return manager.show({ message, type: 'error', duration })
		}
	}

	// Store position in separate context for viewport
	setContext(TOAST_POSITION_KEY, position)

	// Store and return main context
	return setContext(TOAST_CONTEXT_KEY, context)
}

/**
 * Retrieves the toast context.
 * Must be called from a component inside ToastProvider.
 */
export function getToastContext(): ToastContext {
	if (!hasContext(TOAST_CONTEXT_KEY)) {
		throw new Error(
			'Toast context not found. ' + 'Ensure this component is inside a <ToastProvider>.'
		)
	}
	return getContext<ToastContext>(TOAST_CONTEXT_KEY)
}

/**
 * Retrieves the toast position configuration.
 * Used internally by ToastViewport.
 */
export function getToastPosition(): ToastPosition {
	if (!hasContext(TOAST_POSITION_KEY)) {
		return 'bottom-right'
	}
	return getContext<ToastPosition>(TOAST_POSITION_KEY)
}

Toast Provider Component

The provider wraps your app and creates the context:

<!-- src/lib/components/toast/ToastProvider.svelte -->
<script lang="ts">
	import { createToastContext, type ToastPosition } from './toast-context.svelte.js'
	import type { Snippet } from 'svelte'

	interface Props {
		/** Default toast duration in milliseconds */
		defaultDuration?: number

		/** Maximum visible toasts */
		maxToasts?: number

		/** Viewport position */
		position?: ToastPosition

		/** Child content */
		children: Snippet
	}

	const {
		defaultDuration = 5000,
		maxToasts = 5,
		position = 'bottom-right',
		children
	}: Props = $props()

	// Create context during component initialization.
	// Wrap in `untrack` to intentionally capture initial prop values
	// and avoid state_referenced_locally warnings.
	untrack(() => {
		createToastContext(defaultDuration, maxToasts, position)
	})
</script>

{@render children()}

Toast Viewport Component

The viewport renders all active toasts in a fixed position:

<!-- src/lib/components/toast/ToastViewport.svelte -->
<script lang="ts">
	import { getToastContext, getToastPosition } from './toast-context.svelte.js'
	import ToastItem from './ToastItem.svelte'

	interface Props {
		/** Additional CSS class */
		class?: string
	}

	let { class: className = '' }: Props = $props()

	const toastCtx = getToastContext()
	const position = getToastPosition()
</script>

<div
	class="toast-viewport {className}"
	data-position={position}
	role="region"
	aria-label="Notifications"
	aria-live="polite"
>
	{#each toastCtx.toasts as toast (toast.id)}
		<ToastItem {toast} ondismiss={() => toastCtx.dismiss(toast.id)} />
	{/each}
</div>

<style>
	.toast-viewport {
		position: fixed;
		z-index: 9999;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
		padding: 1rem;
		max-width: 420px;
		width: 100%;
		pointer-events: none;
	}

	/* Allow interaction with individual toasts */
	.toast-viewport > :global(*) {
		pointer-events: auto;
	}

	/* Position variants */
	[data-position='top-right'] {
		top: 0;
		right: 0;
	}

	[data-position='top-left'] {
		top: 0;
		left: 0;
	}

	[data-position='top-center'] {
		top: 0;
		left: 50%;
		transform: translateX(-50%);
	}

	[data-position='bottom-right'] {
		bottom: 0;
		right: 0;
		flex-direction: column-reverse;
	}

	[data-position='bottom-left'] {
		bottom: 0;
		left: 0;
		flex-direction: column-reverse;
	}

	[data-position='bottom-center'] {
		bottom: 0;
		left: 50%;
		transform: translateX(-50%);
		flex-direction: column-reverse;
	}
</style>

Toast Item Component

Individual toast with styling and dismiss button:

<!-- src/lib/components/toast/ToastItem.svelte -->
<script lang="ts">
	import type { Toast } from './types.js'

	interface Props {
		/** Toast data */
		toast: Toast

		/** Dismiss callback */
		ondismiss: () => void
	}

	const { toast, ondismiss }: Props = $props()

	// Type-safe icon mapping
	const icons: Record<Toast['type'], string> = {
		info: 'ℹ️',
		success: '',
		warning: '⚠️',
		error: ''
	}
</script>

<div class="toast toast-{toast.type}" role="alert">
	<span class="toast-icon" aria-hidden="true">
		{icons[toast.type]}
	</span>

	<p class="toast-message">
		{toast.message}
	</p>

	<button type="button" class="toast-dismiss" onclick={ondismiss} aria-label="Dismiss notification">
		<svg
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			stroke-width="2"
			stroke-linecap="round"
			stroke-linejoin="round"
			aria-hidden="true"
		>
			<path d="M18 6L6 18M6 6l12 12" />
		</svg>
	</button>
</div>

<style>
	.toast {
		display: flex;
		align-items: flex-start;
		gap: 0.75rem;
		padding: 1rem;
		border-radius: 8px;
		background: var(--toast-bg, #ffffff);
		box-shadow:
			0 4px 12px rgba(0, 0, 0, 0.15),
			0 1px 3px rgba(0, 0, 0, 0.1);
		border-left: 4px solid;
		animation: toast-slide-in 0.3s ease-out;
	}

	@keyframes toast-slide-in {
		from {
			opacity: 0;
			transform: translateX(100%);
		}
		to {
			opacity: 1;
			transform: translateX(0);
		}
	}

	/* Type-specific border colors */
	.toast-info {
		border-color: var(--toast-info, #3b82f6);
	}

	.toast-success {
		border-color: var(--toast-success, #10b981);
	}

	.toast-warning {
		border-color: var(--toast-warning, #f59e0b);
	}

	.toast-error {
		border-color: var(--toast-error, #ef4444);
	}

	.toast-icon {
		flex-shrink: 0;
		font-size: 1.125rem;
		line-height: 1;
	}

	.toast-message {
		flex: 1;
		margin: 0;
		font-size: 0.9375rem;
		line-height: 1.5;
		color: var(--toast-text, #1e293b);
		word-break: break-word;
	}

	.toast-dismiss {
		flex-shrink: 0;
		width: 20px;
		height: 20px;
		padding: 0;
		background: transparent;
		border: none;
		border-radius: 4px;
		cursor: pointer;
		color: var(--toast-dismiss, #64748b);
		transition:
			color 0.15s,
			background-color 0.15s;
	}

	.toast-dismiss:hover {
		color: var(--toast-dismiss-hover, #1e293b);
		background-color: rgba(0, 0, 0, 0.05);
	}

	.toast-dismiss:focus-visible {
		outline: 2px solid var(--toast-focus, #3b82f6);
		outline-offset: 2px;
	}

	.toast-dismiss svg {
		width: 100%;
		height: 100%;
	}
</style>

Public API Exports

The index file provides a clean public interface:

// src/lib/components/toast/index.ts

// Components
export { default as ToastProvider } from './ToastProvider.svelte'
export { default as ToastViewport } from './ToastViewport.svelte'
export { default as ToastItem } from './ToastItem.svelte'

// Context accessor
export { getToastContext } from './toast-context.svelte.js'

// Types - re-export from types.ts for consumers
export type {
	Toast,
	ToastType,
	ToastOptions,
	ToastContext,
	ToastPosition,
	ToastProviderConfig
} from './types.js'

Using the Toast System

Set up the provider in your root layout:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { ToastProvider, ToastViewport } from '$lib/components/toast'

	const { children } = $props()
</script>

<ToastProvider position="bottom-right" defaultDuration={5000} maxToasts={5}>
	{@render children()}

	<!-- Viewport renders all toasts -->
	<ToastViewport />
</ToastProvider>

Use the toast API anywhere in your application:

<!-- src/routes/+page.svelte -->
<script lang="ts">
	import { getToastContext } from '$lib/components/toast'

	const toast = getToastContext()

	// Simulated async operation
	async function saveDocument(): Promise<void> {
		return new Promise((resolve) => setTimeout(resolve, 500))
	}

	async function handleSave(): Promise<void> {
		try {
			await saveDocument()
			toast.success('Document saved successfully!')
		} catch {
			toast.error('Failed to save document. Please try again.')
		}
	}

	function showExamples(): void {
		toast.info('This is an informational message')
		toast.success('Operation completed!')
		toast.warning('Your session expires in 5 minutes', 10000)
		toast.error('Something went wrong')
	}

	function showPersistent(): void {
		// Using the full options API
		const id = toast.show({
			message: 'This toast stays until dismissed',
			type: 'info',
			duration: 0 // 0 = no auto-dismiss
		})

		// Programmatic dismiss after 8 seconds
		setTimeout(() => toast.dismiss(id), 8000)
	}
</script>

<div class="demo">
	<h1>Toast Demo</h1>

	<div class="buttons">
		<button type="button" onclick={handleSave}>Save Document</button>
		<button type="button" onclick={showExamples}>Show All Types</button>
		<button type="button" onclick={showPersistent}>Persistent Toast</button>
		<button type="button" onclick={() => toast.dismissAll()}>Dismiss All</button>
	</div>
</div>

<style>
	.demo {
		padding: 2rem;
	}

	.buttons {
		display: flex;
		gap: 1rem;
		flex-wrap: wrap;
	}

	button {
		padding: 0.75rem 1.5rem;
		font-size: 1rem;
		border-radius: 6px;
		border: none;
		background: #3b82f6;
		color: white;
		cursor: pointer;
		transition: background-color 0.15s;
	}

	button:hover {
		background: #2563eb;
	}
</style>

Key Differences from Tabs/Accordion

The toast system demonstrates fundamentally different compound component patterns:

AspectTabs/AccordionToast System
ChildrenDeclared in markupCreated at runtime via API
API styleDeclarative compositionImperative function calls
LifecycleMount/unmount with parentTimed auto-dismiss
RenderingInline in component treeFixed-position viewport
State shapeActive ID / Set of IDsDynamic array of objects
RegistrationComponents self-registerItems added via show()
CleanupAutomatic on unmountExplicit timer management

The toast pattern applies whenever you need to create UI elements programmatically—modals triggered by events, command palettes, dynamic notifications, or any overlay that doesn’t fit naturally into the component hierarchy.


Patterns and Best Practices

Building compound components reveals several important patterns.

Component Registration with Effects

Child components need to register with the parent context when they mount and unregister when they unmount. In Svelte 5, use $effect with a cleanup function:

<script>
	const tabs = getTabsContext()

	// Register on mount, unregister on unmount
	$effect(() => {
		tabs.registerTab(id)

		return () => {
			tabs.unregisterTab(id)
		}
	})
</script>

This pattern ensures the parent always knows which children exist, even as they dynamically appear and disappear.

Derived State for Reactive Checks

Instead of calling functions in the template, derive boolean states:

<script>
  const tabs = getTabsContext()

  // ✅ Good: Derive once, use reactively
  let isActive = $derived(tabs.isActive(id))
</script>

<!-- Clean template usage -->
<button class:active={isActive}>

This approach is cleaner and ensures the value updates whenever dependencies change.

Keyboard Navigation

Compound components often need keyboard support for accessibility. Handle this at the container level:

<script>
	function handleKeyDown(event: KeyboardEvent) {
		switch (event.key) {
			case 'ArrowRight':
				// Navigate to next item
				break
			case 'ArrowLeft':
				// Navigate to previous item
				break
			case 'Home':
				// Navigate to first item
				break
			case 'End':
				// Navigate to last item
				break
		}
	}
</script>

<div role="tablist" onkeydown={handleKeyDown}>
	{@render children()}
</div>

ARIA Attributes

Proper ARIA attributes make compound components accessible:

Tabs:

  • Container: role="tablist", aria-orientation
  • Tab: role="tab", aria-selected, aria-controls, tabindex
  • Panel: role="tabpanel", aria-labelledby

Accordion:

  • Trigger: aria-expanded, aria-controls
  • Content: role="region", aria-labelledby, hidden

ID Linking

Components reference each other through ID attributes:

<!-- Tab references its panel -->
<button
  id="tab-{id}"
  aria-controls="tabpanel-{id}"
>

<!-- Panel references its tab -->
<div
  id="tabpanel-{id}"
  aria-labelledby="tab-{id}"
>

This creates the semantic connection that assistive technologies need.


When to Use Compound Components

Compound components work well when:

  • Multiple components work as a unit — Tabs, accordions, menus, dropdown selectors
  • Flexibility in composition matters — Users need to customize structure or add custom elements
  • Internal state should be hidden — The complexity of state management stays inside the system
  • Consistent behavior is required — All tabs should work the same way

Consider simpler alternatives when:

  • The component is standalone — A single button doesn’t need compound patterns
  • Props suffice — If a simple prop API covers all use cases, don’t over-engineer
  • Children don’t need coordination — If child components don’t interact, context adds unnecessary complexity

Performance Considerations

Compound components can scale to complex use cases, but understanding their performance characteristics helps you build efficiently.

Context Lookup Cost

Each child component calls getContext once during initialization. For typical compound components with 5-20 children, this cost is negligible. However, if you’re rendering hundreds of items (like a virtualized list), consider whether the compound pattern is appropriate.

<!-- ✅ Good: Reasonable number of tabs -->
<Tabs>
	<TabList>
		{#each categories as cat (cat.id)}
			<Tab id={cat.id}>{cat.name}</Tab>
		{/each}
	</TabList>
	<!-- panels... -->
</Tabs>

<!-- ⚠️ Consider alternatives for hundreds of items -->
<Accordion>
	{#each thousandsOfItems as item (item.id)}
		<AccordionItem id={item.id}>
			<!-- Each item calls getContext -->
		</AccordionItem>
	{/each}
</Accordion>

For large lists, consider virtualization or a simpler data-driven approach.

Lazy Panel Content

By default, our TabPanel only renders when active. This is efficient—inactive panels don’t exist in the DOM:

{#if isActive}
	<div class="tab-panel">
		{@render children()}
	</div>
{/if}

However, this means content unmounts when switching tabs. If panels contain expensive-to-initialize content (complex forms, heavy visualizations), consider keeping them mounted but hidden:

<!-- Alternative: Keep mounted, toggle visibility -->
<div class="tab-panel" class:hidden={!isActive} hidden={!isActive} inert={!isActive}>
	{@render children()}
</div>

<style>
	.hidden {
		display: none;
	}
</style>

The inert attribute prevents keyboard interaction with hidden content.

Registration Overhead

The registration pattern (calling registerTab in $effect) runs for every child on mount. For dynamic lists that frequently add/remove items, consider batching:

// In context, batch registrations
let pendingRegistrations: string[] = []
let registrationTimeout: ReturnType<typeof setTimeout> | null = null

function registerTab(id: string) {
	pendingRegistrations.push(id)

	if (!registrationTimeout) {
		registrationTimeout = setTimeout(() => {
			tabIds = [...tabIds, ...pendingRegistrations]
			pendingRegistrations = []
			registrationTimeout = null
		}, 0)
	}
}

This batches multiple registrations into a single state update. For most use cases, this optimization isn’t necessary—Svelte already batches synchronous updates.

Toast Timer Cleanup

The toast system creates timers for auto-dismiss. Always clean up timers when toasts are dismissed or the provider unmounts:

// In toast context
function dismiss(id: string) {
	clearTimer(id) // Important: Clear timer before removing
	toasts = toasts.filter((t) => t.id !== id)
}

// If provider could unmount, clean up all timers
$effect(() => {
	return () => {
		for (const timer of timers.values()) {
			clearTimeout(timer)
		}
		timers.clear()
	}
})

Leaked timers can cause memory issues and errors if they fire after unmount.


Conclusion

Compound components represent context at its most elegant. The pattern solves a genuine problem—how do you build flexible UI systems where multiple components work together without forcing developers to manually wire up state management? The answer is to hide the coordination behind a clean compositional API. Developers arrange components declaratively, and context handles the internal communication.

The registration pattern is central to making compound components work. When a Tab mounts, it registers itself with the Tabs context. When it unmounts, it unregisters. This means the parent always knows what children exist, even as they dynamically appear and disappear. The $effect with cleanup function makes this pattern natural in Svelte 5—the effect runs on mount to register, and its cleanup function runs on unmount to unregister.

What makes compound components particularly valuable is that they move complexity to the right place. Keyboard navigation, ARIA attributes, focus management, state synchronization—all of this lives inside the compound component system, implemented once and working for every use. Developers using the components don’t need to think about these details. They compose the pieces, and accessibility and behavior come for free. That’s the goal of good abstractions: hiding complexity while exposing flexibility.


Key Takeaways

Compound components use context to create flexible, composable UI systems:

Context provides coordination — The parent container creates context that children consume. Children register themselves, read shared state, and trigger actions through the context.

Components stay focused — Each component handles one responsibility. The container manages state, triggers handle interaction, content handles display.

Composition enables flexibility — Developers arrange components as needed. They can add custom elements, conditionally render pieces, or place components in different DOM structures.

Registration patterns track children — Using $effect with cleanup functions, children register on mount and unregister on unmount. The parent always knows what exists.

Accessibility comes built-in — ARIA attributes, keyboard navigation, and focus management are handled once in the compound component system, benefiting all uses.

The same patterns apply to many UI components: menus, comboboxes, disclosure widgets, navigation systems, and more. Once you understand the pattern, you can apply it anywhere multiple components need to work together as a coordinated system.


What’s Next

Congratulations! You’ve completed the Context API series.

You now have the knowledge to build sophisticated context-based architectures in Svelte 5. In following articles we will bring everything together with real-world examples using Context API patterns.

  • Theme management — Light/dark mode with system preferences
  • Shopping Cart — E-commerce cart with add/remove functionality
  • Multi-step form Wizard — Centralized form state with validation
  • Authentication System — User login state and protected routes
  • Multi-tenant Saas with Context API — Tenant-specific settings and data

See Also

Accessibility Resources