Skip to examples
Bento / Kitchen sink
Bento / primitives

Disclosure

Content shown and hidden in place. Each trigger sits in a heading, so sections join the page outline.

Accordion

single · multiple · collapsible

single

Members you invite, and nobody else. Owners can change roles at any time.

multiple

Single keeps one open; with collapsible it can close to none. Multiple lets several stay open.

Source src/lib/components/disclosure/doc.ts · src/lib/components/disclosure/accordion/doc.ts · src/lib/components/disclosure/accordion/Accordion.svelte · src/lib/components/disclosure/accordion/AccordionTrigger.svelte · src/lib/components/disclosure/accordion/accordion.module.css

src/lib/components/disclosure/doc.ts

/**
 * disclosure — content shown and hidden in place.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Rules for every member
 *
 * R1  A trigger says what it reveals and whether it is open.
 * R2  Hidden content is removed from the reading order until shown.
 */
export {};

src/lib/components/disclosure/accordion/doc.ts

/**
 * Accordion — sections that expand in place.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Accordion          type ("single" | "multiple"), collapsible?, value?,
 *                        defaultValue?, onValueChange?
 *     AccordionItem      value, REQUIRED; disabled?
 *     AccordionTrigger   the section title; level? (default 3)
 *     AccordionContent   the section body
 *
 * # Behaviour
 *
 * R1  Each trigger sits inside a heading of `level`, so sections join the
 *     outline and can be found by heading navigation.
 * R2  "single" keeps one section open at a time (collapsible lets it close
 *     too); "multiple" lets several stay open.
 * R3  Enter and Space toggle; the trigger announces expanded or collapsed. A
 *     chevron turns to show the state.
 * R4  Sections are separated by `--line` rules; the trigger is at least the
 *     lg control height, so each is easy to hit.
 */
export {};

src/lib/components/disclosure/accordion/Accordion.svelte

<script module lang="ts">
	import type { Snippet } from 'svelte';

	type Base = { children: Snippet; class?: string; disabled?: boolean };

	/* The same shape as Radix's: a single accordion holds a string and may be
	   `collapsible`; a multiple one holds a list. */
	export type AccordionProps = Base &
		(
			| {
					type: 'single';
					/** Allow the open section to close, leaving none open. */
					collapsible?: boolean;
					value?: string;
					defaultValue?: string;
					onValueChange?: (value: string) => void;
			  }
			| {
					type: 'multiple';
					value?: string[];
					defaultValue?: string[];
					onValueChange?: (value: string[]) => void;
			  }
		);
</script>

<script lang="ts">
	import { Accordion as Primitive } from 'bits-ui';
	import { untrack } from 'svelte';
	import { cn } from '$lib/utils/cn';
	import styles from './accordion.module.css';

	let props: AccordionProps = $props();

	// Uncontrolled state, used when the caller passes no `value`.
	let local = $state.raw<string | string[]>(
		untrack(() => props.value ?? props.defaultValue ?? (props.type === 'single' ? '' : []))
	);

	/* bits-ui has no `collapsible`: a single accordion can always close. This
	   refuses the empty value unless the caller allowed it, matching Radix. */
	const attrs = $derived(
		props.type === 'single'
			? {
					type: 'single' as const,
					value: props.value ?? (typeof local === 'string' ? local : ''),
					onValueChange: (value: string) => {
						if (props.type !== 'single') return;
						if (!value && !props.collapsible) return;
						local = value;
						props.onValueChange?.(value);
					}
				}
			: {
					type: 'multiple' as const,
					value: props.value ?? (Array.isArray(local) ? local : []),
					onValueChange: (value: string[]) => {
						if (props.type !== 'multiple') return;
						local = value;
						props.onValueChange?.(value);
					}
				}
	);
</script>

<Primitive.Root {...attrs} disabled={props.disabled} class={cn(styles.root, props.class)}>
	{@render props.children()}
</Primitive.Root>

src/lib/components/disclosure/accordion/AccordionTrigger.svelte

<script lang="ts">
	import { Accordion as Primitive, type AccordionTriggerProps } from 'bits-ui';
	import { ChevronDown } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import styles from './accordion.module.css';

	let {
		level = 3,
		class: className = '',
		children,
		...rest
	}: Omit<AccordionTriggerProps, 'class'> & {
		/** The heading level wrapping the trigger, so sections join the outline. */
		level?: 2 | 3 | 4 | 5 | 6;
		class?: string;
	} = $props();
</script>

<Primitive.Header {level}>
	{#snippet child({ props })}
		<svelte:element this={`h${level}`} {...props} class={styles.heading}>
			<Primitive.Trigger {...rest} class={cn(styles.trigger, className)}>
				<span>{@render children?.()}</span>
				<ChevronDown class={styles.chevron} aria-hidden="true" />
			</Primitive.Trigger>
		</svelte:element>
	{/snippet}
</Primitive.Header>

src/lib/components/disclosure/accordion/accordion.module.css

@layer primitive {
	.root {
		width: 100%;
	}
	.item {
		border-bottom: 1px solid var(--line);
	}
	.heading {
		margin: 0;
	}
	.trigger {
		display: flex;
		width: 100%;
		min-height: var(--control-lg);
		align-items: center;
		justify-content: space-between;
		gap: var(--space-6);
		padding: var(--space-5) var(--space-2);
		border: 0;
		border-radius: var(--radius-1);
		background: none;
		color: var(--ink);
		font: inherit;
		font-size: var(--text-body, var(--text-13));
		font-weight: var(--weight-strong);
		text-align: start;
		cursor: pointer;
	}
	.trigger:where(:hover:not(:disabled)) {
		color: var(--accent);
	}
	.trigger:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: -2px;
	}
	.trigger:disabled {
		opacity: 0.45;
		cursor: not-allowed;
	}
	.chevron {
		width: 14px;
		height: 14px;
		flex: none;
		color: var(--ink-3);
		transition: transform var(--dur-2) var(--ease);
	}
	.trigger[data-state='open'] .chevron {
		transform: rotate(180deg);
	}
	.content {
		overflow: hidden;
		color: var(--ink-2);
		font-size: var(--text-body, var(--text-13));
		line-height: var(--leading-body);
	}
	.inner {
		padding: 0 var(--space-2) var(--space-7);
	}
}