# Accordion

> Disclosure sections that open first and let their lines follow through.

Surfaces · [Docs](https://ui.ssntl.app/docs/components/accordion) · `npx shadcn@latest add @ssntl/accordion`

Installs `components/ui/accordion.tsx`, with `utils`, `motion-kit`. Read this file before you use, change or review it.

## When to use it

- For long, scannable content people read in parts: FAQs, policies, details.

## When not to

- **For content everyone needs.** Show it. For navigation: use Tabs or `SideNav`.

## Usage

```tsx
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

;<Accordion type="single" collapsible>
  <AccordionItem value="shipping">
    <AccordionTrigger>Shipping</AccordionTrigger>
    <AccordionContent>
      <p>Same-day delivery…</p>
    </AccordionContent>
  </AccordionItem>
</Accordion>
```

## Rules for Accordion

- **Follow through & overlap.** The panel opens first (220ms), then each line lifts in 40ms after the last.
- Closing is faster and all at once, because leaving needs less attention than arriving.
- Headers are real buttons inside headings; arrow keys move between them (Radix).
- The chevron turns on `--ease-in-out`; with reduced motion nothing moves and the content just appears.

## Keyboard

| Key           | Action                   |
| ------------- | ------------------------ |
| ↑ / ↓         | Move between headers.    |
| Enter / Space | Open or close a section. |
| Home / End    | First or last header.    |

## How it is built

Disclosure sections (Radix Accordion): arrow keys move between headers, Enter and Space open them, and each header is a real button in a heading. Follow through & overlap: the panel opens first (collapsible), then its rows lift in 40ms apart (follow-through), so the motion settles; closing is faster and all at once. Both turn off under reduced motion.

## UI foundations it applies

- **Section separators** (Spacing & sizing): Separate repeated rows with a thin divider that starts where the text does.
- **Follow through & overlap** (Animation principles): Move the container first and let its children follow, slightly staggered.

## Interaction

- Every control has an accessible name: its visible label, or `aria-label` for icon-only controls.
- Every keyboard-reachable control shows a visible focus ring (2px, offset 2px, the ring token).
- Anything you can do with a pointer you can do with the keyboard.
- Targets are 48px (44px minimum for dense rows), with at least 8px between neighbours (the system's density); the hit area covers the whole visible control.
- Press feedback is 0.96 scale in 150ms (`--es-press`, `--duration-fast`); never below 0.96, never slow, and a disabled control never scales.
- One primary action per view; past three secondary actions, the rest go in a menu.
- Hover styles only on pointer devices; never hide a control's function behind hover.
- **Undo beats confirmation.** A frequent, reversible action runs at once and offers Undo (Order archived. Undo). Confirm only what can't be undone, affects other people or destroys many items. A destructive action is red text, placed last.
- Never disable a submit button until the form is valid; validate on submit. While pending, use `aria-disabled="true"` with a spinner beside the label, since disabled drops focus.

## Motion

- Animate only to explain a change; the interface is complete without motion.
- **Durations from tokens.** `--duration-fast` 150 (press, hover), `--duration-pop` 180 (tooltips, icon swaps), `--duration-menu` 220 (menus, small moves), `--duration-panel` 320 (modals, sheets).
- **Easing from tokens.** `--ease-out` for entrances, exits and presses; `--ease-in-out` for things already on screen moving to a new place; linear for progress only.
- Use transitions for interactions so they can be interrupted; keyframes only for one-off sequences.
- Name the properties you transition (transition-[scale,opacity]); never transition: all.
- **Don't animate on page load.** Content that is already there appears at once; entrance motion is for things that arrive later.
- No custom animation on high-frequency interactions (typing, toggling in a list, hovering a table, keyboard shortcuts, a command palette); the cost repeats every time.
- Never `ease-in` on interface motion: it starts slow, exactly when the person is watching.
- **Never animate from scale(0).** Start at 0.9–0.97 with opacity 0 (`pop-in` starts at 0.96). The one exception is a cross-fading icon (`icon-swap`), which starts at 0.25 behind a blur.
- Popovers and menus grow from their trigger (transform-origin at the trigger); modals stay centered.
- Keep animated blur under 20px; a 2–4px blur masks an imperfect cross-fade.
- `will-change` only once you see first-frame stutter, naming the property that animates (scale, not transform), and never on an element that doesn't animate; it also makes the element the container of fixed descendants.
- Entrances stagger 100ms apart (80ms per word in a headline): each chunk rises 12px out of a 4px blur over `--duration-panel`, and exits travel less, faster.
- Icon cross-fades run on `--duration-pop` with cubic-bezier(0.2, 0, 0, 1), and only for infrequent swaps; a tab's icon or an action revealed on hover swaps instantly. (better-ui suggests 300ms; the system keeps its 180ms token, under the 300ms limit for interface motion.)
- Exits are quicker and travel less than entrances; animate opacity, translate, scale and filter, never layout properties.
- Under reduced motion keep opacity and color only. No state lives in motion alone.

## Typography

- Map each text role to a step of the scale; supporting text never outsizes its title.
- Headings balance (`text-balance`); paragraphs avoid orphans (`text-pretty`) and stay under about 70 characters (`max-w-prose`).
- Align paragraphs to the start edge; center only a short single line.
- Two sizes on one line share a baseline (`items-baseline`).
- Codes, IDs and keys use the mono face (Code), where every character is unambiguous.
- Light weights (100–300) only at 28px and up.
- `user-select`: none only on drag surfaces and on choice labels (a quick double click must toggle, not select); never on content people may copy.

## Design tokens only

- Use the system's tokens for every design value: type roles (`text-caption` 13, `text-label` 15, `text-body` 17, `text-lead` 20, `text-heading` 22, `text-title` 32, `text-headline` 48, `text-display` 72), radii (`rounded-xs` 8 … xl 44), spacing steps, colors, shadows and motion. If a value doesn't exist, take the nearest token. Never write text-[15px], rounded-[24px] or bg-[#…]; `pnpm lint` rejects them.
- Type roles already carry line height, tracking and weight. Pick the role; never add `leading-*` or `tracking-*` on top.
- **Nested corners are concentric.** Inner radius = outer radius − padding − border width (`rounded-outer`-* p-concentric-* `rounded-inner`). Past 24px of padding, or with deliberately uneven padding, each layer keeps its own radius token.
- Separate surfaces by tone, not borders. A border is for structure (a divider) or state (an input, a selection).

## Accessibility baseline

- Text meets 4.5:1 against its surface (3:1 at 24px and up, or 18.67px bold); input borders, focus rings and chart marks meet 3:1, in light and dark. Text on a translucent color (`white/80`, /50, color-mix) is measured as rendered over its background; the theme pairs are checked in lint.
- Meaning never rides on color alone: a state carries a word, an icon or a pattern as well.
- Every page fits a 320px screen at 200% zoom with no horizontal scroll; a grid that holds content uses grid-cols-[minmax(0,1fr)] and flex children get min-w-0. Never set maximum-scale=1 or user-scalable=no.
- A focused element is never hidden under sticky chrome: set scroll-padding to the header's height (and to footers or banners at the bottom).
- On a client-side route change, update the title and move focus to the new page's main content.
- **Respect prefers-reduced-motion.** Keep opacity and color, drop movement, scale and blur.
- Content is never truncated without a way to read all of it; long names wrap. A hover-only title tooltip doesn't count as a way back.

## Writing

- Sentence case everywhere; no ALL CAPS except the Eyebrow role.
- One concept, one word, across navigation, headings and buttons.
- Buttons are a verb and its object and say what happens (Delete product, not OK).
- An error says what went wrong and how to fix it, in the user's language.
- **Status copy follows one pattern.** In progress is the verb in -ing (Saving changes…), done names the object and the past tense with no “successfully” (Changes saved), a failure gives the next step, partial results give counts. Cut “Please”.
- A confirmation names the action and the object (Delete Ceramic cup?), never “Are you sure?”. Deleting an account, a workspace or a store asks for its name to be typed.
- Show known requirements as helper text before input, not only as an error after it. Links never say “Click here”, and no two links on a page say “Learn more”.
- Build each sentence as one translatable message, never concatenated around a variable; join lists with Intl.`ListFormat`.
- **Use real characters.** An en dash for ranges (09:30–17:45), × for dimensions, curly quotes, an ellipsis (…).

## Fixing

- Take the cheapest fix that works, in this order: delete (a separator space would carry, an animation on a frequent action, an ARIA attribute a native element makes redundant), use the platform (the native element or control, the browser's focus ring), reuse what the system has (a token, a spacing step, a motion curve), correct the value, and only then add something new.
- Code that adds what a deletion would fix is a problem in itself; fix it by removing.
- A documented convention settles matters of taste, but never excuses a release blocker.

## Worst-case content

- Build and test with the worst real values, not the happy ones: long hyphenated names with diacritics, unbreakable emails and URLs, German compounds, translations about 30% longer, emoji and right-to-left names.
- Unbreakable strings wrap anywhere; nothing pushes its siblings off a row or out of a container.
- Every image has a fallback and a box: a failed avatar shows initials, a frame keeps its ratio, so loading never shifts the layout.
- **Show data as data.** Escape HTML, never render null, undefined or NaN, and round floating point before display.
- **Check the environment as well.** A 280px column, a 2560px screen, 200% zoom, dark mode, `dir="rtl"` and touch.

## Icons

- One icon set (Lucide) at one stroke: 1.6, the Essential Design spec, beside regular and medium text; a heavier stroke only where a small glyph must read (a 16px check). (better-ui proposes rendered strokes of 1.5–2px with `absoluteStrokeWidth`; the system's documented stroke takes precedence as a matter of style.)
- Inline icons are 1em–1.25em of the text beside them.
- Icons are 20px beside labels and 16px in dense controls, at the size they were drawn; a well or a button, not a bigger glyph, gives them room.
- One SVG per icon, colored with `currentColor`; hover, selected and disabled states come from color and opacity, never from separate assets.
- Decorative icons are `aria-hidden`; an icon that is the only content of a control gets its name from the control.

## Light, dark and languages

- Design both themes from the tokens; dark mode is chosen, not inverted. Check every new pair in both.
- Pass every visible and accessible string as a prop or child, so the component works in any language; never hard-code copy inside it.
- Use logical properties (ms-, me-, start-, end-) so layouts mirror in right-to-left languages; `translateX` and background-position need a right-to-left override. Mirror only icons whose meaning follows reading direction (arrows, chevrons), and wrap mixed-direction values in `<bdi>`.
- **Visual order matches DOM order.** No order, row-reverse or grid placement that reorders content.
- Switching themes pauses transitions for the switch, so every surface changes at once.

## Before you ship

- [ ] Every interactive control has an accessible name.
- [ ] Every keyboard-reachable control shows a visible focus indicator.
- [ ] Nothing is reachable by pointer but not by keyboard.
- [ ] Motion and auto-playing content respect prefers-reduced-motion.
- [ ] Nothing is clipped, overlapped or unreachable at 320px width or 200% zoom.
- [ ] Body and control text pass their contrast ratio.
- [ ] No state or meaning is carried by color alone.
- [ ] Destructive actions have a confirmation, an undo, or a distinct treatment.
- [ ] No content is truncated without a way to reach the full value.
- [ ] No content or control hides past a scroll edge or disclosure without a visible cue.
- [ ] Every error names a way to recover.
- [ ] Semantic colors are used for their meaning only (danger never on a safe action).
- [ ] No state change is carried by motion alone.
- [ ] Nothing focused hides under sticky chrome, and overlays contain their own scroll.
- [ ] Worst-case values (long, empty, many, missing, right-to-left) don't break the layout.

## Review with these skills

Every interface built with ssntl/ui is reviewed with the same skills the library is. Install them once and keep them current:

```bash
npx skills@latest add https://github.com/jakubkrehel/skills --skill better-interface
npx skills@latest add jakubkrehel/make-interfaces-feel-better
npx skills@latest add emilkowalski/skills
```

- **better-interface** (Jakub Krehel): The full review: accessibility, layout, writing, typography, color and polish. Its escalation triggers block a release.
- **make-interfaces-feel-better** (Jakub Krehel): The details: concentric radii, optical alignment, press scale, interruptible and subtle motion, hit areas.
- **emilkowalski/skills** (Emil Kowalski): Design engineering: easing, durations, springs and gestures (emil-design-eng, review-animations, animation-performance) and the worst-case values that break a UI (break-ui).

## Related

- [Tabs](tabs.md): Pill tabs whose selection nests in the track and slides with ease-in-out.
- [Motion kit](motion-kit.md): Small animations on the motion tokens: an error shake, a collapsible height, children that follow through, and a sheet slide.

---

Generated from the ssntl/ui source by `scripts/build-rules.mjs`. The whole rulebook is [ssntl-ui.md](ssntl-ui.md); every component's rules are at https://ui.ssntl.app/rules.
