Skip to content

Accordion component

components specs/components/accordion.kmd

A vertical stack of collapsible disclosure items — each a header (a caller-composed label + a rotating chevron) over a body region shown only while the item is expanded. Two modes: multi-expand (any number open, the default) or single-expand (radio-like — opening one collapses the others). Click or Enter/Space on a header toggles its region; the headers are the keyboard roving unit (Up/Down/Home/End). Exports as an ARIA accordion: a Group of Button headers carrying expanded/collapsed, with a region panel under every open item. Grown from the gov.br GVB-01 disclosure pattern + Material accordion conventions, anchored on cards.kmd.

When this spec applies

Primary triggers

All triggers

Specification body

Accordion

Status: v0.1.0 — Draft. Opened from the KDS × gov.br gap analysis (tools/design-gen/docs/govbr-ds-gap-analysis.md, GVB-01 — disclosure) as stack#391. This spec is written faithful to the shipped Kroma widget (engines/sdk/kroma/src/widget/accordion.rs, kroma#185 on the widget-extensibility substrate kroma#201, height-tween kroma#213), not inferred from a design mock. Every dimension, colour token, keyboard rule and a11y node below is grounded in the widget's code — the consts, the event/semantics methods and the public API. Behaviour the ticket names but the widget does not yet do is listed under Não-escopo / follow-up, not asserted as present.

Normative anchor: there is (was) no accordion.kmd; the widget was built on the closest normative spec — specs/components/cards.kmd (a card is a container of related content clickable as a whole; an accordion is its collapsible sibling). The load-bearing borrowings are cards.kmd R2 (the whole header is the hit area; 2 px focus ring; pointer cursor) and R7 (the header is the operable heading; the panel's controls are reachable by Tab separately; reading order = visual order).

Why it exists

Long forms and settings screens pile up sections the user does not need all at once. An accordion collapses each into a one-line header they can open on demand — the gov.br accordion / disclosure pattern (GVB-01). Its whole value is that a closed panel costs one row and is absent from the tab order and the a11y tree until opened, so a keyboard or screen-reader user is not dragged through content they collapsed.

R1 — Anatomy

The widget is a vertical stack of items (Vec<AccordionItem>), each item composed of, top to bottom:

  • Header — a full-width row, fixed 48 px tall (HEADER_HEIGHT), the entire row being the hit area (cards.kmd R2). It contains:
    • a caller-composed label child (Box<dyn Widget> — a Text, or a Row of icon + Text), inset left by 16 px (HEADER_PAD_H) and vertically centred in the row (the row's content box is bounded by HEADER_PAD_V = 12 px top/bottom);
    • a chevron at the right edge, inset by HEADER_PAD_H, drawn as a 2 px-stroke vector path (font-free — no glyph): pointing right when collapsed, down when expanded (CHEVRON_ARM = 4 px half-arm);
    • a 1 px hairline divider along the header's bottom edge (palette.field_outline), the structural rule between items.
  • Body / panel region — a full-width, raised card surface (palette.card) shown only while the item is drawn open, with the caller-composed body child (Box<dyn Widget>) inset 16 px on every side (BODY_PAD).

The shell owns no font: a font is used only by the caller's label child in paint; the chevron and dividers are vector paths. When the available width is unbounded, the accordion falls back to 320 px (DEFAULT_WIDTH); normally it fills its column (ctx.max.width).

Layout stacks items with no inter-item gap: three collapsed 48 px headers measure exactly 144 px; opening a panel over a 40 px-tall body adds 40 + 2×16 = 72 px (measured in the widget's own tests).

R2 — Expansion modes

Two policies (ExpandMode):

  • Multi (ExpandMode::Multi, the default) — every header is an independent toggle; any number of panels may be open at once.
  • Single (.single() / ExpandMode::Single) — radio-like: opening one item collapses every other one, so at most one panel is open.

Items may be seeded open with .expanded_item(...). In single mode, after the first user toggle exactly one item is open; the exclusion is applied both on user toggle (toggle) and on the controlled set_expanded.

R3 — States

Per header:

  • Collapsed — chevron points right; no panel region is rendered (it contributes a zero-height rect at the baseline, never hit-tested).
  • Expanded — chevron points down; the panel region is drawn as a raised card. The logical expanded flag flips immediately on toggle — a11y, focus and hit-testing read it at once; only the drawn panel height lags during the open/close animation (R6).
  • Hover — an 8 % on-surface wash (palette.hover_overlay, interaction/states.kmd R1) fills the header row, on an enabled header only.
  • Focus — a 2 px focus ring (palette.focus_ring) is stroked around the outer header container, inset −2 px, radius shape.radius_btn + 2 (cards.kmd R2 / states.kmd R2). The ring is the keyboard-nav cue.
  • Active / pressed — a PointerDown on an enabled header captures the pointer and requests focus; the toggle fires on PointerUp only if the release lands back on the pressed header (a press that drifts off cancels).
  • Disabled — two granularities: the whole accordion (set_disabled) or a single item (.disabled_item(...) / set_item_disabled). A disabled header: is dropped from the Tab order and keyboard roving, ignores pointer input (no toggle, no hover wash), shows its chevron dimmed to 38 % alpha (on_surface.with_alpha(0.38)), reports enabled = false to a11y, and yields a Cursor::Default (an enabled header yields Cursor::Pointer).

R4 — Accessibility (ARIA accordion mapping)

semantics() exports, reusing the existing Button + Group roles — no new SemRole:

  • Container — a SemRole::Group named "Accordion", node_id = self.id, enabled = !disabled.
  • Per item, a header button — a SemRole::Button whose:
    • name = the item's accessible label string;
    • value = "expanded" or "collapsed" — the aria-expanded analogue, tracking the logical state;
    • focused = whether it holds keyboard focus;
    • node_id = the item's stable header_id;
    • enabled = !item.disabled && !accordion.disabled.
  • Per OPEN item, a panel region — a SemRole::Group named "{label} panel" (the role="region" / aria-controls target) wrapping the body's semantics subtree.
  • A collapsed panel exports no node — a hidden panel is absent from the a11y tree, matching the DOM.

The header buttons are stable focus stops: header_id depends only on the item's index (id + 1 + i), so it is invariant across expand/collapse re-layouts.

R5 — Keyboard

The headers are the roving unit (the ARIA accordion contract). When a header holds focus:

  • Up / Down — move focus to the previous / next enabled header. This slice clamps at the ends — Up on the first (or Down on the last) is a no-op (no wrap-around; see Não-escopo).
  • Home / End — jump focus to the first / last enabled header.
  • Enter / Space — toggle the focused header's panel.

Tab order (collect_focusables) visits every enabled header AND every focusable inside an expanded body, in visual order — so an open panel's own controls stay reachable (cards.kmd R7). A disabled item's header and (when closed) its body contribute nothing. When focus is not on a header, key events are forwarded to the open bodies (a focused control inside a panel keeps its keys). A fully-disabled accordion contributes no focus stops.

R6 — Motion (Verge tokens)

The panel's height tweens open/closed over motion-medium (duration::MEDIUM) instead of popping (kroma#213): ease-decelerate opening, ease-accelerate closing — arriving vs leaving, per easing-duration.kmd R3, off one clock. The body child is laid out at its full natural height and the region is scaled by an open_fraction in 0.0..=1.0; a settled-closed panel lands on exactly zero height.

Under reduced motion the panel snaps (physics.kmd R1) — the substrate collapses the duration; there is no reduced-motion branch in the widget.

cards.kmd R8 forbids hover-driven height change (feed shift); the accordion's height change is user-initiated (a click/keypress), which is allowed.

R7 — Verge shape / elevation tokens

Colours and geometry are read from the active theme, not hardcoded — a runtime theme switch re-themes the accordion:

  • header surface palette.surface; hover wash palette.hover_overlay; chevron palette.on_surface (0.38 α disabled); divider palette.field_outline; focus ring palette.focus_ring; panel = raised card palette.card.
  • the focus ring's corner radius = shape.radius_btn + 2 (Verge default radius_btn = 12 px dark / 6 px light, per specs/themes/verge.kmd R4).
  • defaults are Palette::dark() + Shape::verge() until set_palette / set_shape adopt the active DS (kroma#189/#194).

R8 — Content-overflow pattern

An expanded panel is painted inside a clip layer bounded to the region rect (push_layer → fill card → paint body → pop_layer). Because the body child is laid out at its full natural height while the region height animates, mid- tween the child is taller than the region — the clip is what stops it spilling over the item below (a bounded-region reveal, content-overflow.kmd R-scroll family). A body that itself overflows composes its own strategy (scroll region / show-more) per content-overflow.kmd; the shell does not truncate for it.

R9 — Public API (grounded)

Builder: Accordion::new(id) (multi mode) → .item(header, label, body) / .expanded_item(...) / .disabled_item(...).single() or .mode(ExpandMode).on_toggle(Box<dyn FnMut(usize, bool)>).

Controlled / reconciler surface: set_expanded(i, val) (updates state without firing on_toggle, applying single-mode exclusion), is_expanded(i), expanded_indices(), set_disabled(d), set_item_disabled(i, d), set_on_toggle(f), clear_callbacks(). The toggle callback is fired (index, expanded) on every user toggle.

R10 — gov.br parity (GVB-01)

The widget realises the gov.br Padrão Digital disclosure / accordion GVB-01 line, blended with Material's accordion conventions and anchored on cards.kmd: the collapsible section list, the single-vs-multi open policy, the rotating disclosure chevron, and the ARIA accordion keyboard + aria-expanded contract are all present (R1–R5). Unlike skip-link.kmd, the styling here is not byte-measured against @govbr-ds/core — it paints one Verge/M3 header + panel; measured token parity against the gov.br .br-* accordion CSS is a follow-up (Não-escopo).

Não-escopo / follow-up

Named here, not silently dropped — each is a distinct follow-up ticket, not an implied part of this spec:

  • Density presets (cards.kmd R1 comfortable/compact/spacious padding) — this slice paints one fixed padding set (48 px header, 16 px pads).
  • Per-preset styling (cards.kmd R6 — material2 corners, windows_95 bevel, brutalist border, …) — one Verge/M3 look only.
  • Chevron rotation tween — the chevron flips instantly; only the panel height tweens (R6). Animating the rotation is open.
  • Disabled-header label dim — the shell dims the state cues it owns (the chevron → 38 %) but cannot recolour/dim the caller-composed label child; a full disabled-label dim needs the host or a follow-up that threads a disabled flag into the child.
  • Wrap-around arrow nav — Up/Down clamp at the ends (R5); wrapping (as an option) is the follow-up.
  • cards.kmd R4 "cards within cards" — a caller concern; the shell does not enforce that a body compose list-items/sections rather than a nested card.
  • A dedicated DisclosureGroup SemRole — the widget reuses Group + Button; a first-class disclosure role is deferred.
  • Measured gov.br .br-* token parity (R10) — visual parity against @govbr-ds/core is unmeasured here.
  • specs/components/cards.kmd — the normative anchor (R2 hit-area/focus-ring, R7 operable heading + Tab-reachable panel, R8 no hover height change).
  • specs/interaction/states.kmd — the 8 % hover wash + 2 px focus ring.
  • specs/patterns/content-overflow.kmd — the bounded-region clip + body overflow.
  • specs/themes/verge.kmd — the shape / colour / radius tokens (R7).
  • specs/motion/easing-duration.kmd + specs/motion/physics.kmd — the arriving/leaving easings and reduced-motion snap (R6).
  • specs/i18n/contract.kmd — the header label / accessible name floor.

Requirements (testable)

Requirement: Header toggles its panel region {#req-accordion-toggle}

A click or Enter/Space on an enabled header SHALL toggle its item between collapsed and expanded, flipping the logical expanded flag immediately (a11y, focus and hit-testing read the new value at once), rendering the raised card panel region only while the item is expanded and rotating the chevron to point down when expanded and right when collapsed.

Scenario: Clicking a collapsed header opens its panel

  • GIVEN an accordion whose first item is collapsed
  • WHEN a PointerDown on the first header is followed by a PointerUp released back on that same header
  • THEN the item's logical expanded flag reads true immediately
  • AND the panel region for that item is rendered as a raised palette.card surface
  • AND the header's chevron points down

Scenario: A press that drifts off the header cancels the toggle

  • GIVEN an enabled collapsed header
  • WHEN a PointerDown captures the header but the PointerUp is released off the pressed header
  • THEN the item stays collapsed
  • AND no panel region is rendered for it

Scenario: Enter on a focused header toggles it

  • GIVEN a collapsed header that holds keyboard focus
  • WHEN the Enter key is pressed
  • THEN the item becomes expanded
  • AND its chevron points down

Requirement: Single-expand mode keeps at most one panel open {#req-accordion-single-mode}

In ExpandMode::Single the accordion SHALL be radio-like: opening one item collapses every other item, so at most one panel is open at any time, with the exclusion applied on both user toggle and the controlled set_expanded; the default ExpandMode::Multi SHALL allow any number of panels open at once.

Scenario: Opening a second item collapses the first in single mode

  • GIVEN a single-mode accordion with item 0 expanded
  • WHEN the user toggles item 1 open
  • THEN item 1 is expanded
  • AND item 0 is collapsed
  • AND exactly one item reports expanded

Scenario: Multi mode leaves other panels open

  • GIVEN a default multi-mode accordion with item 0 expanded
  • WHEN the user toggles item 1 open
  • THEN both item 0 and item 1 are expanded

Requirement: Disabled headers are inert and dropped from navigation {#req-accordion-disabled}

A disabled header — whether the whole accordion (set_disabled) or a single item (set_item_disabled) — SHALL ignore pointer input (no toggle, no hover wash), be dropped from the Tab order and keyboard roving, dim its chevron to 38 % alpha, report enabled = false to a11y, and yield Cursor::Default; an enabled header SHALL yield Cursor::Pointer.

Scenario: A disabled item ignores clicks

  • GIVEN an accordion whose item 1 is disabled and collapsed
  • WHEN a click lands on item 1's header
  • THEN item 1 stays collapsed
  • AND no hover wash is drawn on its row
  • AND its chevron is rendered at 38 % alpha

Scenario: A disabled header is skipped by roving

  • GIVEN focus on an enabled header immediately above a disabled header, followed by an enabled header
  • WHEN the Down arrow key is pressed
  • THEN focus moves past the disabled header to the next enabled header
  • AND the disabled header never holds focus

Requirement: Keyboard roving over headers with clamped arrows {#req-accordion-keyboard}

The headers SHALL be the roving keyboard unit: Up/Down SHALL move focus to the previous/next enabled header and clamp at the ends (no wrap-around), Home/End SHALL jump to the first/last enabled header, Enter/Space SHALL toggle the focused header, and Tab order SHALL visit every enabled header plus every focusable inside an expanded body in visual order.

Scenario: Down arrow clamps at the last header

  • GIVEN focus on the last enabled header of the accordion
  • WHEN the Down arrow key is pressed
  • THEN focus stays on the last header (no wrap to the first)

Scenario: Tab reaches a control inside an open panel

  • GIVEN an accordion with an expanded panel that contains a focusable control
  • WHEN Tab advances from that panel's header
  • THEN focus lands on the control inside the expanded body
  • AND the traversal order follows visual order

Requirement: ARIA accordion semantics mapping {#req-accordion-a11y}

semantics() SHALL export a SemRole::Group container named "Accordion", a SemRole::Button per item whose name is the item label and whose value is "expanded" or "collapsed" tracking the logical state (the aria-expanded analogue), and a SemRole::Group panel named "{label} panel" only for each open item; a collapsed panel SHALL export no node.

Scenario: An open item exports an expanded button and a panel node

  • GIVEN an accordion with item 0 expanded and item 1 collapsed
  • WHEN semantics() is exported
  • THEN item 0's header button reports value = "expanded"
  • AND a Group node named "{item 0 label} panel" is present
  • AND item 1's header button reports value = "collapsed"
  • AND no panel node exists for item 1

References