sveltelean Primitives
Versionv0.2.0 GitHub

Example

An exclusive group
Shipping
Orders ship within two working days from the Rotterdam warehouse.
Returns
Thirty days, with the original packaging.
Warranty
Two years on parts and labour, from the date of delivery.
<div data-slean="accordion">
	<details name="accordion-faq" open>
		<summary>Shipping</summary>
		<div data-slean-part="content">Orders ship within two working days from the Rotterdam warehouse.</div>
	</details>
	<details name="accordion-faq">
		<summary>Returns</summary>
		<div data-slean-part="content">Thirty days, with the original packaging.</div>
	</details>
	<details name="accordion-faq">
		<summary>Warranty</summary>
		<div data-slean-part="content">Two years on parts and labour, from the date of delivery.</div>
	</details>
</div>
Tier 0: the two sources differ only by the stylesheet import. The three items share name="accordion-faq", so opening one closes the one that was open. Everything here works with page JavaScript disabled.

Why this implementation exists

An accordion is a list of disclosures where opening one closes the others. The platform has that as an attribute: <details> elements with the same name form an exclusive group, and the browser closes the open item when another opens. Enter and Space toggle an item, each summary is announced as a button with its expanded state, and find-in-page opens the item that holds a match. None of it needs a script.

Disclosure is the page for one <details>; its styles make every item a row with a rule under it. Accordion uses the same element and the same attribute and adds only a root: a bordered list with rules between the items, rounded ends and an inset focus ring. Pick Disclosure for items that stand alone and Accordion for a set that reads as one block.

The browser owns

  • the open state of every item (the open attribute) and the toggle event
  • closing the open item when another item with the same name opens
  • Enter and Space on each summary, and the summary as a button with an expanded state
  • opening a closed item when find-in-page matches its content

Svelte Lean owns

  • accordion.css: the bordered list, the rules between items, the summary row, the chevron and the height transition
  • the contract: one name per accordion, at most one item rendered open per name
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.

npm install @svelte-lean/styles
stylesheets
import '@svelte-lean/styles/accordion.css';

Give every accordion its own name: the browser groups all <details> of the document that share a value, wherever they are. Render open on at most one item of a group; with more, the browser keeps the first and closes the rest while parsing.

Leave name off when several items may be open. The exclusive group can always be closed completely; an item that must stay visible belongs outside the accordion.

Anatomy

PartElementdata-slean-partRequiredNotes
root<div data-slean="accordion">–yesDirect children are the <details> items. No role and no state of its own.
item<details name="…">–yesThe same name on every item for one open at a time; no name for several.
summary<summary>–yesThe first child of each item; the header the reader activates.
contentthe element after the summarycontentstyles onlyPadding and the muted text color. The height transition does not need it.

Runtime profile

Tier 0: the accordion has no behavior module, the Vite plugin maps accordion to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript.

Accessibility contract

  • Each <summary> is exposed as a button with an expanded state that controls its item; no ARIA attribute is needed.
  • Enter and Space toggle an item; Tab moves between summaries and into open content. The arrow-key movement between headers that the WAI-ARIA accordion pattern marks optional is not provided.
  • A heading inside <summary> is valid HTML, but screen readers differ in whether they still expose it as a heading, so the contract does not require one.
  • <details> has no disabled state and an exclusive group can always be closed completely.

Keyboard

KeyWhenResult
Enter/Spacefocus on a summaryToggles its item (native)
Tab/Shift+TabanywhereMoves between summaries and into open content (native)

Platform features

FeatureBaselineOutside the target
<details>, <summary>, the toggle eventWidely availableNot applicable within the support policy
name for exclusive groupsNewly available since September 2024 (Chrome 120, Safari 17.2, Firefox 130)Every item opens on its own
Find-in-page opens a closed itemChrome 97, Firefox 139, Safari 26.2The match inside a closed item is not found
::details-contentNewly available since September 2025 (Chrome 131, Safari 18.4, Firefox 143)The content appears without a transition
interpolate-size: allow-keywordsChrome 129; not in Firefox or SafariThe content appears without a transition

Without JavaScript

Fully functional, exclusive groups included: the name attribute is read by the browser, not by a script.

Server rendering

Static HTML. Render open on the item that starts expanded; the server output is the final state.

Before hydration

Nothing is attached. An item the reader opens before hydration stays open after it.

Styling

accordion.css draws the root's border and radius, the rule between items, the summary row with its hover fill and inset focus ring, the chevron (two borders on summary::after), the content padding, and the height transition on ::details-content that runs where interpolate-size is supported. The tokens it reads:

TokenDefault (light)Applies to
--slean-bordervar(--slean-neutral-6)border, border-block-start
--slean-radius-lg0.875remborder-radius, border-start-start-radius, border-start-end-radius, border-end-start-radius, border-end-end-radius
--slean-surfaceoklch(100% 0 0)background
--slean-fgvar(--slean-neutral-12)color
--slean-space-30.75remgap
--slean-control-height-lg2.75remmin-block-size
--slean-space-20.5rempadding-block
--slean-space-41rempadding-inline, padding-block
--slean-font-weight-medium500font-weight
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-icon-chevron-downurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-duration-normal160mstransition
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-width2pxoutline-offset
--slean-mutedvar(--slean-neutral-3)background
--slean-fg-mutedvar(--slean-neutral-11)color

State selectors the stylesheet targets, all from the platform or ARIA: ::details-content, :first-child, :focus-visible, :hover, :last-child, [open].

Controlled integration

The open attribute of each item is its state. Bind it per item; an item closed by its exclusive sibling fires toggle, and the binding follows.

faq.svelte
<script lang="ts">
	// Each item's open attribute is its state; bind it. Opening one item of the group closes
	// the other, and Svelte's binding follows through the toggle event.
	const items = [
		{ id: 'shipping', title: 'Shipping', text: 'Orders ship within two working days.' },
		{ id: 'returns', title: 'Returns', text: 'Thirty days, with the original packaging.' }
	];
	let open: Record<string, boolean> = $state({ shipping: true, returns: false });
	const current = $derived(items.find((item) => open[item.id])?.title ?? 'none');
</script>

<div data-slean="accordion">
	{#each items as item (item.id)}
		<details name="checkout-faq" bind:open={open[item.id]}>
			<summary>{item.title}</summary>
			<div data-slean-part="content">{item.text}</div>
		</details>
	{/each}
</div>
<p>Open: {current}</p>

Compatibility notes

Exclusive groups are Baseline since September 2024 (Chrome 120, Safari 17.2, Firefox 130); in older browsers every item opens on its own. Find-in-page opens a closed item in Chrome 97, Firefox 139 and Safari 26.2. ::details-content is Baseline since September 2025, but the height animates only where interpolate-size exists, which is Chrome 129 and not Firefox or Safari; elsewhere the content appears without a transition.

Examples

Several open

Without a name each item opens and closes on its own; the root still draws one list.

Notifications
Email for mentions and replies; nothing else.
Privacy
Your profile is visible to your team only.
Sessions
Two browsers are signed in.
several-open.html
<!-- No name: every item opens and closes on its own. -->
<div data-slean="accordion">
	<details open>
		<summary>Notifications</summary>
		<div data-slean-part="content">…</div>
	</details>
	<details open>
		<summary>Privacy</summary>
		<div data-slean-part="content">…</div>
	</details>
</div>

Testing

Source