registry:ui · NativeHTML
accordion — details/summary, no framework
The platform already ships the accordion: <details>/<summary> carries the toggle, the disclosure state, the keyboard contract, and open-in-SSR — nothing to hydrate, no ARIA to maintain. The group adds the 1px frame, the seams, and opt-in exclusive behavior; the eight style axes resolve once at the group root.
$npx jixoai-ui add accordion
https://ui.jixoai.com/r/accordion.json
overview
What the platform gives, what we add
Everything behavioral is the browser's — toggle, keyboard, disclosure semantics, SSR state. The component owns only the frame, the seam, the chevron, the exclusive opt-in, and a progressive height:auto animation (interpolate-size + ::details-content) that degrades to a native snap everywhere else.
platform-native, free
- toggle + disclosure semantics on
details/summary - keyboard: Enter/Space on the summary — the browser's own
- SSR ships the open state in the HTML — no hydration flash
jixoai additions
- collapsed frame: one 1px border, 1px seams between items
-
exclusive— capture-phase guard closes siblings; works on any details children -
bind:openper item — manual state participates in the same guard - chevron rotation + height:auto animation, reduced-motion aware
usage
Usage
Wrap items in the group for the collapsed frame and opt-in exclusive mode; use a bare item for a one-off disclosure.
<script lang="ts">
import Accordion from '@ui/accordion.svelte';
import AccordionItem from '@ui/accordion-item.svelte';
</script>
<Accordion exclusive>
<AccordionItem>
{#snippet summary()}Shipping{/snippet}
Orders leave the warehouse within 48h.
</AccordionItem>
<AccordionItem>
{#snippet summary()}Returns{/snippet}
30 days, no questions — the label is prepaid.
</AccordionItem>
</Accordion>
<!-- bare item = a one-off disclosure -->
<AccordionItem bind:open>
{#snippet summary()}Details{/snippet}
Anything at all.
</AccordionItem>accordion
A three-item FAQ. Flip the exclusive toggle in the playground — opening one item then closes its siblings, through one capture-phase listener; the third item also demonstrates bind:open surviving the guard.
Shipping 48h
Returns
Warranty
<!--
jixoai accordion (registry/files/ui/accordion/accordion.svelte).
W3C-first: the accordion IS <details>/<summary>. Native toggle
semantics, native keyboard support, native SSR state (the open item
ships open in the HTML) — no ARIA roles to maintain, nothing to
hydrate. This file is the GROUP half; pair with accordion-item.svelte.
The group adds exactly what a pile of bare <details> lacks:
1. border collapse — one 1px frame around the set, 1px seams
between items (not a stack of double borders)
2. exclusive mode — opt-in "radio behavior": opening one item
closes its siblings. Implemented by ONE capture-phase `toggle`
listener on this container (toggle does not bubble, but capture
sees it; Svelte events have no capture modifier, so it is a
manual addEventListener in the action below), and it also
governs raw <details> children a consumer drops in —
composition over registration.
Height animation is progressive enhancement: interpolate-size:
allow-keywords + ::details-content transition animates to
height:auto on supporting engines; everywhere else it snaps (native
behavior, never broken).
tw4 (2026-08-24): utility-authored — the frame and the ghost
variant (conditional utilities, same element) live in the markup;
ONLY the sibling seam (a `> * + *` boundary no utility may own) and
the @supports interpolate-size gate stay in accordion.css (D1-exempt
residue, static @layer components).
tailwindless one-shot W1 (2026-09-17): the frame and ghost paint
ride the family's stylex ATOMS (accordion.stylex.ts — structural
constants verbatim, theme slots on tokens/ruler channels); the css
keeps the sibling seam + the interpolate-size gate.
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { fromAction } from 'svelte/attachments';
import { cn } from '$lib/utils';
import {
densityRungOf,
provideQueryAnchor,
provideUniversalLanes,
stampCarriersForLanes,
type ColorLane,
type DensityLane,
type ElevationLane,
type MotionLane,
type QueryResult,
type RadiusLane,
type ShapeLane,
type SizeLane,
type ThemeLane,
} from '$lib/defaults.svelte';
import { AccordionDefaults } from './accordion-defaults.svelte';
import { accordionStyles } from './accordion.stylex';
import './accordion.css';
interface Props {
/** radio behavior: opening one DIRECT child closes its siblings
* (nested accordions and wrapper divs do not participate) */
exclusive?: boolean;
/** antd's ghost paint: frameless — no outer border/card, items keep
* only the hairline separators (antd Collapse ghost mapping) */
ghost?: boolean;
/** density policy: the universal §4 lane (named rungs + the
* documented small/medium/large aliases · auto · a coefficient
* number · query()) */
density?: DensityLane | QueryResult<DensityLane>;
/** universal size axis (§1): root font-size — named steps · auto
* (inherit) · a px number · query() (ONE number moves summary +
* body: the whole disclosure set scales) */
size?: SizeLane | QueryResult<SizeLane>;
/** universal shape axis (§2): corner geometry; auto = inherit */
shape?: ShapeLane | QueryResult<ShapeLane>;
/** universal radius axis (§3): corner size; auto = the concentric
* broadcast (the frame SUPPLIES the anchor; a nested auto
* consumer computes R−inset) */
radius?: RadiusLane | QueryResult<RadiusLane>;
/** universal color axis (§5): the hue axis of the oklch system */
color?: ColorLane | QueryResult<ColorLane>;
/** universal theme axis (§6): light/dark/system; auto = tree
* inheritance (the .dark class bridge) */
theme?: ThemeLane | QueryResult<ThemeLane>;
/** universal elevation axis (§7): official M3 levels · dp ·
* query() */
elevation?: ElevationLane | QueryResult<ElevationLane>;
/** universal motion axis (§8): intensity — reduced…expressive ·
* a coefficient · query() */
motion?: MotionLane | QueryResult<MotionLane>;
children: Snippet;
class?: string;
style?: string;
}
let {
exclusive = false,
ghost = false,
density,
size,
shape,
radius,
color,
theme,
elevation,
motion,
children,
class: className = '',
style: consumerStyle,
}: Props = $props();
// ── the eight-axis surface (W3-D5 — FIRST-TIME contract, all
// no-own): ONE resolve record at the GROUP root — the frame div
// stamps the §10 carriers (JOINing the consumer style attr, the
// merge law), supplies downward via provideUniversalLanes and
// anchors query() after the anchor state declaration (the W3-C TDZ
// law). accordion-item rides the supply chain: its details/summary
// content is IN-FLOW inside the frame (the native-disclosure
// architecture has no portal boundary — the content inherits the
// frame's stamped carriers through the plain cascade; the portal
// law has nothing to self-carry here)
const d = $derived(
AccordionDefaults.resolve({ density, size, shape, radius, color, theme, elevation, motion }),
);
const carriers = $derived(stampCarriersForLanes(d));
provideUniversalLanes({ density, size, shape, radius, color, theme, elevation, motion });
let uniRoot = $state<HTMLDivElement>();
provideQueryAnchor(() => uniRoot ?? null);
const rootStyle = $derived(
[carriers, consumerStyle ?? undefined].filter(Boolean).join('; ') || undefined,
);
// the payload's own join (separator's serialize law, tailwindless
// task 2.1): stylex members are objects in dev and joined strings
// in shipped payloads — composition goes through THIS joiner (all
// string values except $$css, space-joined), never a raw
// interpolation (an object renders [object Object]).
const cx = (
...styles: ({ readonly [key: string]: string | object } | undefined | string)[]
): string =>
styles
.filter(Boolean)
.map((style) =>
typeof style === 'string'
? style
: Object.entries(style ?? {}).flatMap(([key, value]) =>
key !== '$$css' && typeof value === 'string' ? [value] : [],
).join(' '),
)
.join(' ');
/** capture-phase toggle delegation (see header note); parameterized so
* a dynamic `exclusive` flip takes effect without a remount. Mounted
* through the fromAction bridge (effect-attachments): an action-shaped
* {update, destroy} return cannot ride {@attach} bare — only FUNCTION
* returns are teardown, the object return would leak silently */
function exclusiveGuard(node: HTMLElement, enabled: boolean) {
let on = enabled;
const handler = (event: Event) => {
if (!on) return;
const details = event.target;
if (!(details instanceof HTMLDetailsElement) || !details.open) return;
if ((event as ToggleEvent).newState !== 'open') return;
for (const sibling of node.querySelectorAll(':scope > details[open]')) {
if (sibling !== details) sibling.removeAttribute('open');
}
};
node.addEventListener('toggle', handler, true);
return {
update(next: boolean) {
on = next;
},
destroy: () => node.removeEventListener('toggle', handler, true),
};
}
</script>
<div
bind:this={uniRoot}
data-jx-accordion-ghost={ghost ? '' : undefined}
class="jx-accordion {cn(cx(accordionStyles.group, ghost && accordionStyles.ghost), className)}"
data-density={densityRungOf(d.density)}
class:dark={d.theme === 'dark'}
style={rootStyle}
{@attach fromAction(exclusiveGuard, () => exclusive)}
>
{@render children()}
</div>
postures
Postures
The group frame, the ghost paint, and the bare one-off disclosure — all the same native details/summary underneath.
accordion · postures
default — framed
framed
seams
ghost
ghost
bare item
one-off disclosure
<script lang="ts">
import Accordion from '@ui/accordion.svelte';
import AccordionItem from '@ui/accordion-item.svelte';
</script>
<div class={cx(rt.gridMd3)}>
<div class={cx(rt.panel)}>
<!-- default — the framed group -->
<p class={cx(rt.eyebrow, rt.mb12, rt.inkMuted)}>default — framed</p>
<Accordion>
<AccordionItem>
{#snippet summary()}framed{/snippet}
One collapsed 1px border around the set.
</AccordionItem>
<AccordionItem>
{#snippet summary()}seams{/snippet}
1px seams between items, not double borders.
</AccordionItem>
</Accordion>
</div>
<div class={cx(rt.panel)}>
<!-- ghost — antd Collapse ghost paint, frameless -->
<p class={cx(rt.eyebrow, rt.mb12, rt.inkMuted)}>ghost</p>
<Accordion ghost>
<AccordionItem>
{#snippet summary()}ghost{/snippet}
antd Collapse ghost mapping — frameless, hairline separators only.
</AccordionItem>
</Accordion>
</div>
<div class={cx(rt.panel)}>
<!-- bare item — without the group: a single styled details/summary -->
<p class={cx(rt.eyebrow, rt.mb12, rt.inkMuted)}>bare item</p>
<AccordionItem>
{#snippet summary()}one-off disclosure{/snippet}
Without the group: a single styled details/summary.
</AccordionItem>
</div>
</div>props
Props
Two halves: the group owns the frame and the exclusive guard; the item is a styled details/summary with a snippet summary.
Accordion (group)
| Property | Type | Default | Description |
|---|---|---|---|
| exclusive | boolean | false | Radio behavior: opening one direct child closes its siblings via a capture-phase toggle listener. |
| ghost | boolean | false | antd Collapse ghost paint — frameless, hairline separators only. |
| children | Snippet | — | AccordionItem (or raw details) children. |
| class | string | '' | Forwarded to the group container. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
AccordionItem
| Property | Type | Default | Description |
|---|---|---|---|
open bind | boolean | false | Disclosure state; bindable (bind:open) for controlled use. |
| summary | Snippet | — | The summary line — plain text or a composed snippet (no interactive elements). |
| children | Snippet | — | The expanded body. |
| class | string | '' | Forwarded to the details element. |
axes
The eight axes on the accordion
One Defaults contract resolved at the GROUP root (accordion.svelte's frame div); the items ride the supply chain. Native-details content is in flow — the frame's stamped carriers reach nested consumers through the plain cascade, no portal boundary. All eight lanes are carried, all no-own: every default is auto. The split, counted: density is the one full repainter; theme is a partial painter (the row names which voices flip and which stay); radius is anchor-only; shape and color supply real nested consumers; size, elevation, and motion are supply-only with no reader in the tree — recorded per axis instead of silently omitted. The fleet grammar lives on the universal-props concept page; this table is what each axis drives HERE.
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | Stamps --jx-size-effective plus an inline font-size on the group root (a px number). The inline stamp reaches only unstyled flow directly under the frame — the summary and body re-anchor on the density channels (var(--jx-text)), and Card/PressButton voices are token-anchored: no consumer in the tree reads the carrier (grep receipt; the kernel's own stamp emitter is the only other hit). Supply-only for the disclosure's anatomy (documented absence). |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | Stamps --jx-shape-effective and --jx-radius-factor-effective on the group root. The family CSS consumes neither — the corner geometry supplies real nested consumers (the concentric demo's Card corners itself through them: corner-shape: var(--jx-shape-effective, round) plus the factor in its radius calc). |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | Stamps --jx-radius-effective on the frame — the §3 concentric anchor (a px number). The frame's own corners keep the ambient var(--radius); a nested radius="auto" consumer computes max(0px, radius − inset): 20 computes 6px under the Card's 0.875rem inset (the census D5 receipt). |
| density | 'small' | 'medium' | 'large' | 'xs' | '2xs' | 'sm' | 'default' | 'lg' | 'auto' | number | 'auto' | The one axis the family paints. A named rung stamps the data-density scope (small/medium/large alias sm/default/lg; the five legacy rungs stay addressable), swapping var(--jx-text)/var(--jx-line) — summary and body text move (11 · 12 · 13 · 15px at xs/sm/default/lg). A number (a coefficient) stamps --jx-density-coefficient — inert on this family: the scope blocks substitute at their declaring element, so the coefficient alone repaints nothing; only a named rung's attr re-anchors --jx-text on the frame. The seam and summary paddings are ruler equations off --jx-unit (rem-fixed) and never scale. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | Stamps --jx-color-effective (a hue number is degrees in the oklch formula; a raw string passes through). The family's own ink stays on theme tokens (--foreground, hover --primary-text, focus --ring) — the hue supplies real nested consumers (a fill PressButton inside an item re-hues: --jx-fill/--jx-tonal resolve on var(--jx-color-effective, var(--primary))). |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | A PARTIAL re-theme on this family, measured (the theme-split law): the resolved dark step puts the .dark class on the frame (the §6 class bridge), which re-scopes the raw-token voices — the summary ink (--foreground, hover --primary-text), the focus ring (--ring), and the item seam (--border) flip to the dark profile — while the family's stylex voices stay at page-scope resolution (the card ground --jx-card, the frame border --jx-border, the body ink --jx-muted-foreground keep their light values): the semantic-ink re-scope gap (the drift ledger's W-next #1), documented per voice until the protocol pass. light and system ride tree inheritance. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | Stamps --jx-elevation-effective (a number is exact dp). The frame carries no shadow and no consumer in the tree reads the carrier (grep receipt: zero hits; the Card's shadow is the fixed token --jx-shadow-2xs and never reads the lane). Supply-only (documented absence). |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | Stamps --jx-motion-effective (a number is a coefficient). The disclosure animation keeps its fixed recipe (--motion-200 / --motion-ease-nav, killed under prefers-reduced-motion) and no family or fleet component reads the carrier (grep receipt: zero hits in lib/ui — the tree's only reader is the component-canvas demo page). Supply-only (documented absence). |
Deviations, cited: the five supply-only lanes (size · shape · color · elevation ·
motion) — the family consumes none of their carriers, per the broadcast protocol
(吃也供, supply-and-consume; the universal-props concept page owns the term) —
and the anchor-only radius lane are recorded in migration-census.md (openspec/changes/explicit-props/research, the W3-D5 rows) — so an explicit lane
reaches the disclosure's nested content, not the disclosure chrome. density is the
only full repainter; theme is a partial one — the raw-token voices flip (summary
ink, focus ring, seam), the stylex voices stay (the W-next #1 gap, named per voice
in the row above).
accordion · density
shipping
shipping
shipping
shipping
density="large" — 15px summary
large
density="small" — 12px summary
small
<script lang="ts">
import Accordion from '@ui/accordion.svelte';
import AccordionItem from '@ui/accordion-item.svelte';
import DensityDemo from '@lib/ui/density-demo/density-demo.svelte';
</script>
<div class={cx(rt.col20, rt.wFull)}>
<DensityDemo>
<Accordion>
<AccordionItem>
{#snippet summary()}shipping{/snippet}
Orders leave the warehouse within 48h.
</AccordionItem>
</Accordion>
</DensityDemo>
<!-- explicit rungs — the documented vocabulary (aliases of sm/lg) -->
<div class={cx(rt.gridSm2)}>
<div class={cx(rt.panel)}>
<p class={cx(rt.eyebrow, rt.mb12, rt.inkMuted)}>density="large" — 15px summary</p>
<Accordion density="large">
<AccordionItem>
{#snippet summary()}large{/snippet}
The lg rung — 15px summary text.
</AccordionItem>
</Accordion>
</div>
<div class={cx(rt.panel)}>
<p class={cx(rt.eyebrow, rt.mb12, rt.inkMuted)}>density="small" — 12px summary</p>
<Accordion density="small">
<AccordionItem>
{#snippet summary()}small{/snippet}
The sm rung — 12px summary text.
</AccordionItem>
</Accordion>
</div>
</div>
</div>accordion · theme
A dark island, unretouched — a PARTIAL re-theme, measured: the raw-token voices re-scope (the summary ink is now white — the unreadable line IS the documented gap), while the family's stylex voices keep the light profile (card ground --jx-card, frame border --jx-border, body ink --jx-muted-foreground). The semantic-ink re-scope gap, the drift ledger's W-next #1 — documented per voice until the protocol pass.
warranty
returns
<script lang="ts">
import Accordion from '@ui/accordion.svelte';
import AccordionItem from '@ui/accordion-item.svelte';
</script>
<div class={cx(rt.wFull)}>
<Accordion theme="dark">
<AccordionItem>
{#snippet summary()}warranty{/snippet}
Two years against defects — the summary ink flips to the dark profile; the card
ground stays light.
</AccordionItem>
<AccordionItem>
{#snippet summary()}returns{/snippet}
30 days, no questions asked.
</AccordionItem>
</Accordion>
</div>accordion · concentric radius
concentric anchor
<script lang="ts">
import Accordion from '@ui/accordion.svelte';
import AccordionItem from '@ui/accordion-item.svelte';
import Card from '@ui/card.svelte';
</script>
<div class={cx(rt.wFull, rt.maxWXl)}>
<Accordion radius={20}>
<AccordionItem>
{#snippet summary()}concentric anchor{/snippet}
<Card radius="auto">
<div class={cx(rt.panel)}>the auto Card computes max(0px, 20px − its 0.875rem inset) = 6px — the radius var inherits the group root stamp through the plain cascade (native-details content is in flow; no portal boundary)</div>
</Card>
</AccordionItem>
</Accordion>
</div>accordion · query()
shipping
returns
<script lang="ts">
import Accordion from '@ui/accordion.svelte';
import AccordionItem from '@ui/accordion-item.svelte';
import { query } from '@lib/universal-props-query.svelte';
import type { DensityLane } from '@lib/defaults.svelte';
</script>
<div class={cx(rt.wFull, rt.maxWXl)}>
<Accordion density={query<{ sm: DensityLane }, DensityLane>({ sm: 'default' }, 'small')}>
<AccordionItem>
{#snippet summary()}shipping{/snippet}
The compact rhythm below the 40rem viewport — the default rhythm above. Resize
the window and watch the summary step.
</AccordionItem>
<AccordionItem>
{#snippet summary()}returns{/snippet}
30 days, no questions asked.
</AccordionItem>
</Accordion>
</div>a11y
Accessibility
The component adds zero ARIA of its own — the browser maps details/summary to the disclosure pattern, open state included. Focus-visible draws a 1px inset outline (--ring); the disclosure animation is killed under prefers-reduced-motion; the summary snippet must not contain interactive elements — they fight the summary's own click/keyboard contract. The summary's hit height is density-invariant: 11px padding-block at default, sm, and lg alike (measured) — density moves the text, not the target.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus to the summary line |
| Enter / Space | Toggles the focused item open/closed (native summary behavior) |
ARIA
| Attribute | Value | Description |
|---|---|---|
| details / summary | native semantics | The platform exposes name, role, and open state; no ARIA attributes are added or needed. |