Accordion
A group of headed panels that open one at a time or several at once, set as one hairline panel of rows or as separate cards. Each row can carry its current value, so closed rows still read as a summary.
cmp_accordion_group_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_accordion_group_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-e18575627ae181baea1f51faa72b736d7b9d99edd5c2c605249fbb6e2c104464
<script module lang="ts">
import type { Snippet } from 'svelte';
export interface AccordionItem {
/** Stable key, unique within one accordion. `value` holds these. */
id: string;
title: string;
/** Short current value or summary set on the row, such as "Team · 14 seats". */
meta?: string;
/** Plain text (line breaks are kept) or a snippet for rich content. */
content: Snippet | string;
/** Shown but not operable; announced as unavailable and skipped by the arrow keys. */
disabled?: boolean;
}
</script>
<script lang="ts">
import { flushSync, tick, untrack } from 'svelte';
interface Props {
items: AccordionItem[];
/** `single` keeps at most one panel open; `multiple` opens them independently. */
type?: 'single' | 'multiple';
/** Single mode only: whether the open panel can be closed again. */
collapsible?: boolean;
/** Ids of the open panels. Bindable. */
value?: string[];
/** Called with the new open ids whenever the visitor opens or closes a panel. */
onValueChange?: (ids: string[]) => void;
/** Heading level wrapping each trigger, to continue the page's outline. */
headingLevel?: 2 | 3 | 4 | 5 | 6;
/** `bordered` is one hairline panel of rows; `separated` gives each item its own card. */
variant?: 'bordered' | 'separated';
/** A chevron that turns over, or a plus that becomes a minus. */
icon?: 'chevron' | 'plus';
/** Up, Down, Home and End move focus between triggers. */
arrowNavigation?: boolean;
}
let {
items,
type = 'single',
collapsible = true,
value = $bindable([]),
onValueChange,
headingLevel = 3,
variant = 'bordered',
icon = 'chevron',
arrowNavigation = true
}: Props = $props();
const uid = $props.id();
const heading = $derived(`h${headingLevel}`);
/* The APG keeps region landmarks for accordions of six panels or fewer. */
const regions = $derived(items.length <= 6);
/** Known ids only, each once; single mode keeps the first. */
const open = $derived.by(() => {
const known = new Set(items.map((item) => item.id));
const ids = [...new Set(value ?? [])].filter((id) => known.has(id));
return type === 'single' ? ids.slice(0, 1) : ids;
});
/** Panels still visible while their close animation runs. */
let closing = $state<string[]>([]);
/* State, so bind:this has a reactive target; nothing in the markup reads them. */
const triggers = $state<HTMLButtonElement[]>([]);
const panels = $state<HTMLElement[]>([]);
/** Animations in flight by item id, with a null prototype so an id like "constructor" is safe. */
const running: Record<string, { animation: Animation; panel: HTMLElement } | undefined> =
Object.create(null);
/** The open set the last visitor action produced, so a change from outside can be recognised. */
let committed = '';
let destroyed = false;
const ENTER = 'cubic-bezier(0.16, 1, 0.3, 1)';
const EXIT = 'cubic-bezier(0.4, 0, 1, 1)';
const panelFor = (id: string) => panels[items.findIndex((item) => item.id === id)];
const signature = () => JSON.stringify([open, items.map((item) => item.id), type]);
/*
* Find-in-page can reveal a closed panel before hydration, when no beforematch handler exists
* yet. Hydration would hide it again, so adopt what the server markup shows. On the server and
* on a fresh mount there is nothing to read, and this finds nothing.
*/
function adoptRevealed() {
if (typeof document === 'undefined') return;
const revealed = items.flatMap((item, index) => {
const panel = document.getElementById(`${uid}-panel-${index}`);
return panel && !panel.hidden && !item.disabled && !open.includes(item.id) ? [item.id] : [];
});
if (revealed.length > 0)
value = type === 'single' ? revealed.slice(0, 1) : [...open, ...revealed];
}
adoptRevealed();
/*
* A change that did not come from a visitor (a new value, new items, a new type) settles every
* panel at once: no animation is left running against state it no longer matches.
*/
$effect.pre(() => {
const current = signature();
if (current === committed) return;
committed = current;
untrack(() => {
for (const id of Object.keys(running)) stop(id);
if (closing.length > 0) closing = [];
});
});
$effect(() => () => {
destroyed = true;
for (const id of Object.keys(running)) stop(id);
});
function motion(): 'height' | 'fade' | 'none' {
if (typeof window === 'undefined' || typeof Element.prototype.animate !== 'function')
return 'none';
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'fade' : 'height';
}
function toggle(index: number) {
const item = items[index];
if (!item || item.disabled) return;
const expanded = open.includes(item.id);
if (expanded && type === 'single' && !collapsible) return;
const next = expanded
? open.filter((id) => id !== item.id)
: type === 'single'
? [item.id]
: [...open, item.id];
commit(next, motion());
}
/** Find-in-page revealed a closed panel: the browser has already shown it, so no animation. */
function reveal(index: number) {
const item = items[index];
if (!item || item.disabled || open.includes(item.id)) return;
commit(type === 'single' ? [item.id] : [...open, item.id], 'none');
}
function commit(next: string[], mode: 'height' | 'fade' | 'none') {
const opened = next.filter((id) => !open.includes(id));
const closed = open.filter((id) => !next.includes(id));
// Measure before anything changes, so a reversal starts from where the panel is now.
const from: Record<string, number> = Object.create(null);
for (const id of [...opened, ...closed]) {
const panel = panelFor(id);
from[id] = panel && !panel.hidden ? panel.getBoundingClientRect().height : 0;
stop(id);
}
const settled = closing.filter((id) => !opened.includes(id) && !closed.includes(id));
closing = mode === 'height' ? [...settled, ...closed] : settled;
value = next;
committed = signature();
onValueChange?.(next);
if (mode === 'none') return;
void tick().then(() => {
if (destroyed) return;
// A later action may have reversed a panel before this ran; animate only what still holds.
for (const id of opened) if (open.includes(id)) animate(id, from[id] ?? 0, true, mode);
if (mode === 'height')
for (const id of closed) if (closing.includes(id)) animate(id, from[id] ?? 0, false, mode);
});
}
/** Stop one panel's animation without running its callbacks, and release its clipping. */
function stop(id: string) {
const entry = running[id];
if (!entry) return;
delete running[id];
entry.animation.onfinish = null;
entry.animation.cancel();
entry.panel.style.overflow = '';
}
function animate(id: string, from: number, opening: boolean, mode: 'height' | 'fade') {
stop(id);
const panel = panelFor(id);
if (!panel) {
if (!opening) closing = closing.filter((c) => c !== id);
return;
}
// Reduced motion: nothing moves; an opening panel fades in and a closing one goes at once.
if (mode === 'fade') {
const fade = panel.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: 150,
easing: 'cubic-bezier(0.2, 0, 0, 1)'
});
running[id] = { animation: fade, panel };
fade.onfinish = () => stop(id);
return;
}
const to = opening ? panel.getBoundingClientRect().height : 0;
panel.style.overflow = 'hidden';
const animation = panel.animate([{ height: `${from}px` }, { height: `${to}px` }], {
duration: opening ? 220 : 160,
easing: opening ? ENTER : EXIT,
fill: opening ? 'none' : 'forwards'
});
running[id] = { animation, panel };
animation.onfinish = () => {
if (running[id]?.animation !== animation) return;
// Hide before the held frame is released, so the panel never flashes open again.
if (!opening) flushSync(() => (closing = closing.filter((c) => c !== id)));
stop(id);
};
}
/** Up, Down, Home and End move between enabled triggers and wrap, per the APG pattern. */
function onKeydown(event: KeyboardEvent, index: number) {
if (!arrowNavigation || event.defaultPrevented) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
const enabled = items.flatMap((item, i) => (item.disabled ? [] : [i]));
if (enabled.length === 0) return;
const at = enabled.indexOf(index);
let next: number | undefined;
if (event.key === 'ArrowDown')
next =
at === -1
? (enabled.find((i) => i > index) ?? enabled[0])
: enabled[(at + 1) % enabled.length];
else if (event.key === 'ArrowUp')
next =
at === -1
? ([...enabled].reverse().find((i) => i < index) ?? enabled[enabled.length - 1])
: enabled[(at - 1 + enabled.length) % enabled.length];
else if (event.key === 'Home') next = enabled[0];
else if (event.key === 'End') next = enabled[enabled.length - 1];
else return;
event.preventDefault();
triggers[next]?.focus();
}
</script>
{#if items.length > 0}
<div
class={[
'accordion-group h-fit w-full',
variant === 'separated'
? 'grid gap-3'
: 'overflow-hidden rounded-xl border border-[var(--_hairline)] bg-[var(--_surface)]'
]}
data-variant={variant}
>
{#each items as item, index (index)}
{@const expanded = open.includes(item.id)}
{@const visible = expanded || closing.includes(item.id)}
{@const locked = expanded && type === 'single' && !collapsible}
{@const inert = item.disabled || locked}
{@const first = variant === 'separated' || index === 0}
{@const last = variant === 'separated' || index === items.length - 1}
<div
class={[
variant === 'separated'
? 'accordion-group__card overflow-hidden rounded-xl border border-[var(--_hairline)] bg-[var(--_surface)]'
: index > 0 && 'border-t border-[var(--_hairline)]'
]}
data-open={expanded ? '' : undefined}
>
<svelte:element this={heading} class="m-0">
<button
bind:this={triggers[index]}
type="button"
id="{uid}-trigger-{index}"
aria-expanded={expanded}
aria-controls="{uid}-panel-{index}"
aria-disabled={inert ? 'true' : undefined}
data-disabled={item.disabled ? '' : undefined}
onclick={() => toggle(index)}
onkeydown={(event) => onKeydown(event, index)}
class={[
'accordion-group__trigger flex w-full items-start gap-4 px-4 py-3 text-start focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--_accent)] sm:px-5',
item.disabled ? 'cursor-not-allowed' : locked ? 'cursor-default' : 'cursor-pointer',
first && 'rounded-t-[11px]',
last && !visible && 'rounded-b-[11px]'
]}
>
<!-- A value short enough sits at the end of the title's line; a long one wraps beneath it. -->
<span
class="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:flex-wrap sm:items-baseline sm:justify-between sm:gap-x-4"
>
<span
class={[
'accordion-group__title text-base leading-6 font-medium tracking-[-0.011em] text-pretty wrap-anywhere sm:min-w-0 sm:flex-auto',
item.disabled ? 'text-[var(--_muted)]' : 'text-[var(--_ink)]'
]}
>
{item.title}
</span>
{#if item.meta}
<span
class="text-sm leading-5 wrap-anywhere text-[var(--_muted)] tabular-nums sm:min-w-0"
>
{item.meta}
</span>
{/if}
</span>
<!-- One line tall, so the mark stays level with the first line of a wrapping title. -->
<span
class={['flex h-6 shrink-0 items-center', item.disabled && 'opacity-40']}
aria-hidden="true"
>
{#if icon === 'plus'}
<svg class="accordion-group__icon size-4" viewBox="0 0 16 16" fill="none">
<path
d="M3.5 8h9"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
<path
class="accordion-group__bar"
d="M8 3.5v9"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
{:else}
<svg
class="accordion-group__icon accordion-group__chevron size-4"
viewBox="0 0 16 16"
fill="none"
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</span>
</button>
</svelte:element>
<!-- until-found keeps closed panels searchable with find-in-page; disabled ones stay shut. -->
<div
bind:this={panels[index]}
id="{uid}-panel-{index}"
role={regions ? 'region' : undefined}
aria-labelledby={regions ? `${uid}-trigger-${index}` : undefined}
hidden={visible ? undefined : item.disabled ? true : 'until-found'}
inert={!expanded && visible}
onbeforematch={() => reveal(index)}
>
<div class="px-4 pb-4 sm:px-5">
{#if typeof item.content === 'string'}
<p
class="accordion-group__body max-w-[30em] text-sm leading-[1.6] text-pretty wrap-anywhere whitespace-pre-line text-[var(--_muted)]"
>
{item.content}
</p>
{:else}
<div class="accordion-group__body text-sm leading-[1.6] text-[var(--_muted)]">
{@render item.content()}
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
<style>
/* Public tokens: set --accordion-group-* on this element or any ancestor to retone it. */
.accordion-group {
--_accent: var(--accordion-group-accent, #18181b);
--_ink: var(--accordion-group-ink, #18181b);
--_muted: var(--accordion-group-muted, #52525b);
--_hairline: var(--accordion-group-hairline, rgb(0 0 0 / 0.1));
--_surface: var(--accordion-group-surface, #ffffff);
/* Hover and press fills are steps of the ink, so a retone carries them along. */
--_hover: color-mix(in srgb, var(--_ink) 4%, transparent);
--_press: color-mix(in srgb, var(--_ink) 7%, transparent);
}
/*
* The focus ring is drawn inset: rows sit edge to edge inside a clipped, rounded frame, so an
* outset ring would be cut off by the frame or cross the neighbouring row.
*/
.accordion-group__trigger {
transition: background-color 150ms cubic-bezier(0.2, 0, 0, 1);
}
.accordion-group__trigger:not([aria-disabled='true']):hover {
background-color: var(--_hover);
}
.accordion-group__trigger:not([aria-disabled='true']):active {
background-color: var(--_press);
transition-duration: 80ms;
}
.accordion-group__title:dir(rtl) {
letter-spacing: 0;
}
/* Japanese has no spaces; break at phrase boundaries rather than mid-word (Chromium 119+). */
.accordion-group__title:lang(ja),
.accordion-group__body:lang(ja) {
word-break: auto-phrase;
}
.accordion-group__icon {
color: var(--_muted);
transition: color 150ms cubic-bezier(0.2, 0, 0, 1);
}
.accordion-group__trigger:not([aria-disabled='true']):not([aria-expanded='true']):hover
.accordion-group__icon {
color: var(--_ink);
}
[aria-expanded='true'] .accordion-group__icon {
color: var(--_accent);
}
/* Closing uses the exit curve at 160 ms; opening uses the enter curve at 220 ms. */
.accordion-group__chevron,
.accordion-group__bar {
transform-box: fill-box;
transform-origin: center;
transition:
transform 160ms cubic-bezier(0.4, 0, 1, 1),
color 150ms cubic-bezier(0.2, 0, 0, 1);
}
[aria-expanded='true'] .accordion-group__chevron {
transform: rotate(180deg);
}
/* The plus loses its upright stroke by turning it flat onto the other one. */
[aria-expanded='true'] .accordion-group__bar {
transform: rotate(90deg);
}
[aria-expanded='true'] .accordion-group__chevron,
[aria-expanded='true'] .accordion-group__bar {
transition:
transform 220ms cubic-bezier(0.16, 1, 0.3, 1),
color 150ms cubic-bezier(0.2, 0, 0, 1);
}
/* Separated cards: the open card is the one lifted surface, lit from above. */
.accordion-group__card {
transition: box-shadow 220ms cubic-bezier(0.2, 0, 0, 1);
}
.accordion-group__card[data-open] {
box-shadow:
0 1px 2px rgb(0 0 0 / 0.04),
0 8px 24px rgb(0 0 0 / 0.06);
}
@media (prefers-reduced-motion: reduce) {
.accordion-group__chevron,
.accordion-group__bar,
[aria-expanded='true'] .accordion-group__chevron,
[aria-expanded='true'] .accordion-group__bar {
transition: none;
}
}
</style>
Usage#
On this pageSupply items with an id, a title, optional meta and content as text or a snippet. The component tracks which panels are open and reports changes through value and onValueChange; it does not fetch content on open, persist state or sync it to the URL, and it is not a tabs replacement.
- Suggested location
src/lib/components/accordion-group-01- Required props
items
Limitations
- Clicks and keys work only after hydration; panels listed in value are open in the server HTML.
- Only visitor actions animate. Changing value, items or type from outside settles every panel at once, without animation.
- Open state is keyed by item id, so ids must be unique within one accordion; unknown ids in value are ignored, and single mode keeps only the first.
- Closed panels use hidden="until-found": supporting browsers reveal a match through find-in-page and the panel is marked open once hydrated; other browsers keep closed panels hidden. Disabled panels are never revealed this way.
- An empty items array renders nothing; show your own message or omit the component.
Example
<script lang="ts">
import Accordion, { type AccordionItem } from '$lib/components/accordion-group-01/Accordion.svelte';
const items: AccordionItem[] = [
{
id: 'billing',
title: 'Plan and billing',
meta: 'Team · 14 seats',
content: 'Team is $12 per seat per month, billed yearly.'
},
{
id: 'export',
title: 'Export and backups',
content: 'Export the workspace as JSON or CSV at any time.'
}
];
let open = $state(['billing']);
</script>
<Accordion {items} bind:value={open} headingLevel={3} />Using the accordion#
Copy Accordion.svelte into src/lib/components/accordion-group-01/. It is a control, not a section: it fills the width of whatever column you put it in and brings no heading or padding of its own. Put the heading that introduces the group beside or above it, and set headingLevel one level below that heading.
Writing the rows#
Each row reads best as a noun phrase ("Plan and billing", "Allergens") with an optional meta value that says where things stand: the current setting, a price, a duration. With every panel closed, the rows then work as a summary on their own, and people open only the one they need. Keep meta short; from the sm breakpoint it sits at the end of the row in tabular numerals, and on phones it drops under the title.
Content is plain text or a snippet. Text keeps its line breaks and is capped at about 65 characters a line. For lists, links or controls, pass a snippet:
{#snippet exportPanel()}
<p>Download the workspace as <a href="/export.json">JSON</a> or <a href="/export.csv">CSV</a>.</p>
{/snippet}
<Accordion items={[{ id: 'export', title: 'Export and backups', content: exportPanel }]} />Open state#
value is the list of open ids, and you can bind it. In single mode only the first id counts. onValueChange fires after the visitor opens or closes a panel, with the new list. Setting value yourself opens and closes panels without animation; only visitor actions animate.
With collapsible={false} in single mode, the open panel stays open until another is chosen. Its trigger is marked aria-disabled so assistive technology says it cannot be closed, and it loses its hover fill.
disabled items stay visible and focusable with Tab, so the title and meta can say why they are unavailable ("Business plan"). The arrow keys skip them. A disabled item listed in value is shown open and cannot be closed.
Variants#
bordered sets the items as rows in one hairline frame, which suits settings groups and specs. separated gives each item its own card and lifts the open one with a soft shadow, which suits a handful of longer questions on a marketing or information page. icon="plus" swaps the chevron for a plus that becomes a minus.
Motion#
Panels grow to their height over 220 ms and close over 160 ms, and a reversed click starts from wherever the panel is. Under prefers-reduced-motion: reduce, panels fade in instead, close at once, and the mark turns without animating.
Retoning#
Set the five variables on the component or any ancestor. For a cream bakery page:
.cakes {
--accordion-group-surface: #fffdf8;
--accordion-group-hairline: rgb(68 40 20 / 0.12);
--accordion-group-ink: #2b1d14;
--accordion-group-muted: #6b5344;
--accordion-group-accent: #b4532a;
}The hover and press fills are mixed from ink, so they follow a retone without their own variables. Keep the accent at 3:1 against the surface and muted at 4.5:1.
Right to left#
Spacing and alignment use logical properties, so under dir="rtl" titles start on the right and the mark moves to the left with no changes. Title tracking resets to zero for Arabic and Hebrew.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
items | AccordionItem[] | Yes | None | Panels in display order: { id: string; title: string; meta?: string; content: Snippet | string; disabled?: boolean }. Text content keeps its line breaks. |
type | 'single' | 'multiple' | No | 'single' | single keeps at most one panel open; multiple lets panels open independently. |
collapsible | boolean | No | true | Single mode only. When false, the open panel stays open until another opens, and its trigger is marked aria-disabled. |
value | string[] | No | [] | Ids of the open panels. Bindable. |
onValueChange | (ids: string[]) => void | No | None | Called with the open ids after the visitor opens or closes a panel. |
headingLevel | 2 | 3 | 4 | 5 | 6 | No | 3 | Heading level wrapping each trigger, so the group continues the page's outline. |
variant | 'bordered' | 'separated' | No | 'bordered' | bordered sets the items as rows in one hairline panel; separated gives each its own card and lifts the open one. |
icon | 'chevron' | 'plus' | No | 'chevron' | A chevron that turns over when open, or a plus that becomes a minus. |
arrowNavigation | boolean | No | true | Up and Down move focus between enabled triggers and wrap; Home and End go to the first and last. |
Customization#
On this pageChange content through items and behaviour through props; retone through five CSS variables: accent (focus ring and open mark), ink, muted, hairline and surface. Hover and press fills are derived from ink.
- Content: give each item a short title and, where it helps, a meta value such as the setting's current state, a price or a duration. Text content keeps line breaks; pass a snippet for lists, links or controls.
- Behaviour: type='multiple' for independent panels; collapsible=
{false}for a single mode where one panel is always open once chosen; bind:value to read or set the open ids. - Headings: set
headingLevelone below the heading that introduces the group. - Accent:
--accordion-group-accentcolours the focus ring and the mark of an open row. The default is near-black, so the group renders monochrome until you choose a colour; keep it at 3:1 against the surface. - Text:
--accordion-group-inksets titles and derives the hover and press fills;--accordion-group-mutedsets meta values, panel text and resting marks. Keep muted at 4.5:1 against the surface. - Surfaces:
--accordion-group-hairlinedraws the frame and the rules between rows;--accordion-group-surfacefills the panel or cards (white by default). - Worked retone for a cream bakery page:
--accordion-group-surface:#fffdf8;--accordion-group-hairline: rgb(68 40 20 / 0.12);--accordion-group-ink:#2b1d14;--accordion-group-muted:#6b5344;--accordion-group-accent:#b4532a. - Worked retone for a dark page:
--accordion-group-surface:#18181b;--accordion-group-hairline: rgb(255 255 255 / 0.1);--accordion-group-ink:#fafafa;--accordion-group-muted:#a1a1aa;--accordion-group-accent:#93c5fd. The open card's shadow is invisible on dark; the open mark still carries the state. - Density: rows are
px-4py-3(px-5from sm), 48 px tall with one line. Change both on the trigger and the panel's inner div so text stays on one edge. - Radius: the frame and cards are
rounded-xl; the first and last triggers use rounded-[11px] so hover fills and focus rings follow the frame. Change them together.
Public CSS variables
| Variable | Token |
|---|---|
--accordion-group-accent | accent |
--accordion-group-ink | ink |
--accordion-group-muted | muted |
--accordion-group-hairline | hairline |
--accordion-group-surface | surface |
Accessibility#
On this page- Follows the WAI-ARIA APG accordion pattern: each trigger is a native button inside a heading of the chosen level, with
aria-expandedandaria-controlspointing at its panel. - Panels are labelled regions while there are six or fewer; with more, the region role is dropped so landmark lists stay short, as the APG advises.
- Enter and Space toggle through the native button. Up and Down move focus between enabled triggers and wrap; Home and End go to the first and last. Keys held with Ctrl, Alt, Meta or Shift are left to the browser. Set
arrowNavigation={false}to leave only Tab. - Disabled items stay in the Tab order with
aria-disabled, so their title and meta can explain why. Visitors cannot open or close them, the arrow keys skip them and find-in-page cannot reveal them; a disabled item you list in value is shown open. - In single mode with collapsible off, the open trigger is marked
aria-disabled, which tells screen reader users it cannot be closed. It stays focusable by Tab and by the arrow keys, since it is the current item rather than an unavailable one. - The chevron or plus is decorative and hidden from assistive technology; state is conveyed by
aria-expandedand the mark's colour and orientation together. - Focus is a two-pixel ring in the accent, drawn inside the row because rows sit edge to edge in a clipped frame. Keep
--accordion-group-accentat 3:1 against the surface. - Under reduced motion panels fade in instead of growing, close at once, and the mark turns without animating.
- A closing panel is inert for the length of its animation, so its content leaves the Tab order and the accessibility tree as soon as its trigger reports it collapsed.
- IDs derive from
$props.id(), so several accordions on one page never collide and server and client markup match.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.