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:
| Component | Responsibility |
|---|---|
Tabs | Container that provides context and manages state |
TabList | Wrapper for tab triggers with proper ARIA role |
Tab | Individual tab trigger button |
TabPanel | Content 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:
| Component | Responsibility |
|---|---|
| Context | Shared state and methods for tab coordination |
Tabs | Container that provides context and manages state |
TabList | Wrapper for tab triggers with proper ARIA role |
Tab | Individual tab trigger button |
TabPanel | Content 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:
| Prop | Description |
|---|---|
defaultTab | ID of the initially active tab |
orientation | Tab orientation |
onTabChange | Callback -fn- when active tab changes |
class | Additional CSS class |
children | Child 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:
| Prop | Description |
|---|---|
class | additional css class |
children | tab 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:
| Prop | Description |
|---|---|
id | unique identifier for this tab |
disabled | whether this tab is disabled |
class | additional css class |
children | tab 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:
| Prop | Description |
|---|---|
id | ID matching the associated Tab |
class | additional css class |
children | panel 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.
| Component | Responsibility |
|---|---|
| Context | Shared state and methods for accordion control |
Accordion | Container that provides context and manages state |
AccordionItem | Individual expandable/collapsible section |
AccordionTrigger | Button to toggle expansion of an item |
AccordionContent | Content 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.
| Prop | Description |
|---|---|
single | Only allow one item open at a time |
defaultExpanded | Initially expanded item IDs |
onExpandedChange | Callback -fn- when expanded items change |
class | Additional CSS class |
children | Child 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.
| Prop | Description |
|---|---|
id | Unique identifier for this item |
class | additional css class |
children | Item 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.
| Prop | Description |
|---|---|
itemId | ID of the accordion item |
class | additional css class |
children | trigger 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.
| Prop | Description |
|---|---|
itemId | ID of the accordion item |
class | additional css class |
children | content 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:
| Aspect | Tabs/Accordion | Toast System |
|---|---|---|
| Children | Declared in markup | Created at runtime via API |
| API style | Declarative composition | Imperative function calls |
| Lifecycle | Mount/unmount with parent | Timed auto-dismiss |
| Rendering | Inline in component tree | Fixed-position viewport |
| State shape | Active ID / Set of IDs | Dynamic array of objects |
| Registration | Components self-register | Items added via show() |
| Cleanup | Automatic on unmount | Explicit 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
Related Articles
- Providing Context — Context fundamentals
- Reactive Context Fundamentals — Reactive patterns
- Authentication System — Auth context example
- Shopping Cart — Cart context example