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.
Yes. Changes take effect at the start of the next billing period.
In the region chosen when the workspace was created.
multiple
Members you invite, and nobody else. Owners can change roles at any time.
Yes. Changes take effect at the start of the next billing period.
In the region chosen when the workspace was created.
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);
}
}