jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — accordion

$npx jixoai-ui add accordion

https://ui.jixoai.com/r/accordion.json

native details/summary SSR-stable open state capture-phase exclusive guard height:auto animation eight axes at the group root

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:open per 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.

Accordion usage
<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
Orders leave the warehouse within 48 hours — tracking lands in your inbox the moment the label prints.
Returns
30 days, no questions asked. The return label is prepaid; refunds post within two business days of arrival.
Warranty
Two years against defects. Repairs run through the same pipeline as returns — one form, either outcome.
accordion.svelte svelte
<!--
  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
One collapsed 1px border around the set.
seams
1px seams between items, not double borders.

ghost

ghost
antd Collapse ghost mapping — frameless, hairline separators only.

bare item

one-off disclosure
Without the group: a single styled details/summary.
accordion-postures-demo.svelte svelte
<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)

PropertyTypeDefaultDescription
exclusive booleanfalseRadio behavior: opening one direct child closes its siblings via a capture-phase toggle listener.
ghost booleanfalseantd Collapse ghost paint — frameless, hairline separators only.
children Snippet—AccordionItem (or raw details) children.
class string''Forwarded to the group container.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)

AccordionItem

PropertyTypeDefaultDescription
open bindbooleanfalseDisclosure 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.

PropertyTypeDefaultDescription
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

xs
shipping
Orders leave the warehouse within 48h.
sm
shipping
Orders leave the warehouse within 48h.
default
shipping
Orders leave the warehouse within 48h.
lg
shipping
Orders leave the warehouse within 48h.

density="large" — 15px summary

large
The lg rung — 15px summary text.

density="small" — 12px summary

small
The sm rung — 12px summary text.
accordion-density-demo.svelte svelte
<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
Two years against defects — the summary ink flips to the dark profile; the card ground stays light.
returns
30 days, no questions asked.
accordion-theme-demo.svelte svelte
<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
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)
accordion-concentric-demo.svelte svelte
<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
The compact rhythm below the 40rem viewport — the default rhythm above. Resize the window and watch the summary step.
returns
30 days, no questions asked.
accordion-query-demo.svelte svelte
<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

KeyAction
TabMoves focus to the summary line
Enter / SpaceToggles the focused item open/closed (native summary behavior)

ARIA

AttributeValueDescription
details / summarynative semanticsThe platform exposes name, role, and open state; no ARIA attributes are added or needed.
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h