Component
FAQ Accordion
A realistic frequently-asked-questions accordion: question/answer pairs with roomier region padding, body-sm answer copy, and semantic question text in the trigger (the question IS the button label — no redundant ARIA question roles).
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/accordion/accordion-faq/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Accordion/accordion-faq React/Components/Accordion/accordion-faq import { createContext, useContext, useId, useState } from "react";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
/**
* DevSnips React Accordion — FAQ variant.
*
* Question/answer disclosure: the question is the trigger label (sitting in
* its `h3` wrapper, so questions stay in the page outline), the answer is
* the region. Multiple mode lets readers keep answers open side by side;
* region padding is roomier for reading. No special "FAQ" chrome — the
* pattern is content, not styling. Shares the entire reference core.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const TRIGGER_CLASSES =
"flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const CHEVRON = (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
/* ------------------------------------------------------------------------ */
/* Accordion context (root state) */
/* ------------------------------------------------------------------------ */
interface AccordionContextValue {
accordionId: string;
isOpen: (value: string) => boolean;
toggleItem: (value: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion(component: string): AccordionContextValue {
const context = useContext(AccordionContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Accordion>.`);
}
return context;
}
/* ------------------------------------------------------------------------ */
/* AccordionItem context (per-item state) */
/* ------------------------------------------------------------------------ */
interface AccordionItemContextValue {
value: string;
open: boolean;
disabled: boolean;
triggerId: string;
contentId: string;
toggle: () => void;
}
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
function useAccordionItem(component: string): AccordionItemContextValue {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <AccordionItem>.`);
}
return context;
}
/* ------------------------------------------------------------------------ */
/* Accordion (root provider) */
/* ------------------------------------------------------------------------ */
// `defaultValue` is omitted from the forwarded div attributes because the
// Accordion API re-purposes it as the initial open value (the native
// attribute only applies to form fields).
interface AccordionBaseProps extends Omit<HTMLAttributes<HTMLDivElement>, "defaultValue"> {
/**
* Single mode only: allow the open item to be closed by activating its
* trigger again. When `false` (default), activating the open item's
* trigger is a no-op — exactly one item stays open once one has been
* opened. Ignored when `type="multiple"` (items always toggle freely).
*/
collapsible?: boolean;
children?: ReactNode;
}
export interface AccordionSingleProps extends AccordionBaseProps {
/** Expansion mode: at most one item open at a time (default). */
type?: "single";
/** Controlled open value (`null` = nothing open). */
value?: string | null;
/** Initial open value when uncontrolled. */
defaultValue?: string | null;
/** Called with the next open value (`null` when all items are closed). */
onValueChange?: (value: string | null) => void;
}
export interface AccordionMultipleProps extends AccordionBaseProps {
/** Expansion mode: all items may be open at once. */
type: "multiple";
/** Controlled open values. */
value?: string[];
/** Initial open values when uncontrolled. */
defaultValue?: string[];
/** Called with the next array of open values. */
onValueChange?: (value: string[]) => void;
}
export type AccordionProps = AccordionSingleProps | AccordionMultipleProps;
/** Normalize a mode-shaped value to the internal open-value list. */
function toOpenList(value: string | string[] | null | undefined): string[] {
if (value == null) return [];
return Array.isArray(value) ? value : [value];
}
/**
* The accordion root. Owns the open-item state and provides it to every
* item. Internally the state is always a `string[]` of open values; the
* single-mode API only ever stores zero or one entry. The public props are
* a discriminated union so `value` / `onValueChange` always match `type`.
*/
export function Accordion(props: AccordionProps) {
const {
type = "single",
collapsible = false,
value,
defaultValue,
onValueChange,
className,
children,
...divProps
} = props;
const isMultiple = type === "multiple";
const generatedId = useId();
const accordionId = `accordion-${generatedId}`;
const isControlled = value !== undefined;
const [internalOpen, setInternalOpen] = useState<string[]>(() =>
toOpenList(defaultValue),
);
const openValues = isControlled ? toOpenList(value) : internalOpen;
const toggleItem = (itemValue: string): void => {
const open = openValues.includes(itemValue);
let next: string[];
if (isMultiple) {
next = open
? openValues.filter((entry) => entry !== itemValue)
: [...openValues, itemValue];
} else if (open) {
if (!collapsible) return;
next = [];
} else {
next = [itemValue];
}
if (!isControlled) setInternalOpen(next);
// The public type guarantees the callback matches the mode: single
// receives `string | null`, multiple receives `string[]`.
(onValueChange as ((next: string | string[] | null) => void) | undefined)?.(
isMultiple ? next : (next[0] ?? null),
);
};
const context: AccordionContextValue = {
accordionId,
isOpen: (itemValue: string) => openValues.includes(itemValue),
toggleItem,
};
return (
<AccordionContext.Provider value={context}>
<div className={cx("w-full min-w-0", className)} {...divProps}>
{children}
</div>
</AccordionContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* AccordionItem */
/* ------------------------------------------------------------------------ */
export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
/**
* Unique, id-safe identifier for this item within the accordion. It keys
* the open state and derives the trigger/region ids, so keep it stable
* and free of whitespace.
*/
value: string;
/** Disable the item: the trigger cannot be focused or activated. */
disabled?: boolean;
children?: ReactNode;
}
/**
* One accordion entry: a bordered row in the divided list (`border-b`,
* removed on the last item). Computes its open state from the root and
* derives the stable trigger/region ids that wire the disclosure
* relationship.
*/
export function AccordionItem({
value,
disabled = false,
className,
children,
...rest
}: AccordionItemProps) {
const { accordionId, isOpen, toggleItem } = useAccordion("AccordionItem");
const open = isOpen(value);
const context: AccordionItemContextValue = {
value,
open,
disabled,
triggerId: `${accordionId}-trigger-${value}`,
contentId: `${accordionId}-content-${value}`,
toggle: () => {
if (!disabled) toggleItem(value);
},
};
return (
<AccordionItemContext.Provider value={context}>
<div
className={cx(
"border-b border-[var(--ds-color-border)] last:border-b-0",
className,
)}
{...rest}
>
{children}
</div>
</AccordionItemContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* AccordionTrigger */
/* ------------------------------------------------------------------------ */
// `disabled` lives on AccordionItem (it disables the whole item), so it is
// omitted from the forwarded button attributes.
export interface AccordionTriggerProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "disabled"> {
/**
* Leading visual affordance (an icon). Rendered `aria-hidden` — it must
* supplement the trigger text, never replace it.
*/
icon?: ReactNode;
/**
* Short status/count text rendered as a neutral pill at the trailing
* edge (for example `"3"`, `"Beta"`, `"4 errors"`). It is plain text
* inside the button, so it becomes part of the trigger's accessible
* name — keep it short and meaningful.
*/
badge?: ReactNode;
/**
* A short supporting line rendered under the title. It is part of the
* button's accessible name, so keep it brief.
*/
description?: ReactNode;
children?: ReactNode;
}
/**
* The disclosure control: a real `<button type="button">` inside an `<h3>`
* heading (so the accordion participates in the page outline). Exposes
* `aria-expanded` + `aria-controls` pointing at the region, toggles on
* native button activation (click, Enter, Space), and carries the trailing
* chevron that rotates when open (`aria-hidden` — the state is exposed by
* `aria-expanded`, not by the glyph). A custom `onClick` runs first and may
* veto the toggle with `event.preventDefault()`. The focus ring is drawn
* inset (`-outline-offset-2`) so full-bleed triggers inside bordered
* containers keep an unclipped ring.
*/
export function AccordionTrigger({
icon,
badge,
description,
className,
children,
onClick,
type,
...rest
}: AccordionTriggerProps) {
const { open, disabled, triggerId, contentId, toggle } =
useAccordionItem("AccordionTrigger");
return (
<h3 className="m-0 flex">
<button
type={type ?? "button"}
id={triggerId}
aria-expanded={open}
aria-controls={contentId}
disabled={disabled}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) toggle();
}}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{icon != null && icon !== false ? (
<span
aria-hidden="true"
className="inline-flex size-4 shrink-0 items-center justify-center text-[var(--ds-color-muted-foreground)] [&_svg]:size-4"
>
{icon}
</span>
) : null}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="break-words text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">
{children}
</span>
{description != null && description !== false ? (
<span className="break-words text-[13px] font-normal leading-5 text-[var(--ds-color-muted-foreground)]">
{description}
</span>
) : null}
</span>
{badge != null && badge !== false ? (
<span className="inline-flex h-5 shrink-0 items-center justify-center rounded-[var(--ds-radius-full)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-subtle)] px-1.5 text-[11px] font-medium leading-none text-[var(--ds-color-muted-foreground)]">
{badge}
</span>
) : null}
<span
aria-hidden="true"
className={cx(
"inline-flex size-4 shrink-0 items-center justify-center text-[var(--ds-color-muted-foreground)] transition-transform duration-200 ease-out motion-reduce:transition-none",
open && "rotate-180",
)}
>
{CHEVRON}
</span>
</button>
</h3>
);
}
/* ------------------------------------------------------------------------ */
/* AccordionContent */
/* ------------------------------------------------------------------------ */
export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
/**
* The collapsible region: `role="region"` labelled by its trigger, with the
* stable id the trigger's `aria-controls` references. Height animates via
* the CSS grid-rows trick (0fr <-> 1fr) — no JavaScript measurement — and a
* discrete `visibility` transition hides the region from the accessibility
* tree and tab order the moment the close transition completes (instantly
* under `prefers-reduced-motion`). The region stays mounted while closed so
* state inside it survives. `className` and forwarded attributes (for
* example `aria-busy`) land on the inner content div.
*/
export function AccordionContent({
className,
children,
...rest
}: AccordionContentProps) {
const { open, triggerId, contentId } = useAccordionItem("AccordionContent");
return (
<div
id={contentId}
role="region"
aria-labelledby={triggerId}
className={cx(
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div
className={cx(
"min-h-0 overflow-hidden transition-[visibility] duration-200 motion-reduce:transition-none",
open ? "visible" : "invisible",
)}
>
<div
className={cx(
"break-words px-4 pb-4 text-sm leading-6 text-[var(--ds-color-muted-foreground)]",
className,
)}
{...rest}
>
{children}
</div>
</div>
</div>
);
}
export default Accordion; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import { createContext, useContext, useId, useState } from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const TRIGGER_CLASSES = "flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left transition-colors duration-150 ease-out hover:bg-[var(--ds-color-surface-hover)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none";
const CHEVRON = <svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
aria-hidden="true"
focusable="false"
>
<path d="m6 9 6 6 6-6" />
</svg>;
const AccordionContext = createContext(null);
function useAccordion(component) {
const context = useContext(AccordionContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Accordion>.`);
}
return context;
}
const AccordionItemContext = createContext(null);
function useAccordionItem(component) {
const context = useContext(AccordionItemContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <AccordionItem>.`);
}
return context;
}
function toOpenList(value) {
if (value == null) return [];
return Array.isArray(value) ? value : [value];
}
function Accordion(props) {
const {
type = "single",
collapsible = false,
value,
defaultValue,
onValueChange,
className,
children,
...divProps
} = props;
const isMultiple = type === "multiple";
const generatedId = useId();
const accordionId = `accordion-${generatedId}`;
const isControlled = value !== undefined;
const [internalOpen, setInternalOpen] = useState(
() => toOpenList(defaultValue)
);
const openValues = isControlled ? toOpenList(value) : internalOpen;
const toggleItem = (itemValue) => {
const open = openValues.includes(itemValue);
let next;
if (isMultiple) {
next = open ? openValues.filter((entry) => entry !== itemValue) : [...openValues, itemValue];
} else if (open) {
if (!collapsible) return;
next = [];
} else {
next = [itemValue];
}
if (!isControlled) setInternalOpen(next);
onValueChange?.(
isMultiple ? next : next[0] ?? null
);
};
const context = {
accordionId,
isOpen: (itemValue) => openValues.includes(itemValue),
toggleItem
};
return <AccordionContext.Provider value={context}>
<div className={cx("w-full min-w-0", className)} {...divProps}>
{children}
</div>
</AccordionContext.Provider>;
}
function AccordionItem({
value,
disabled = false,
className,
children,
...rest
}) {
const { accordionId, isOpen, toggleItem } = useAccordion("AccordionItem");
const open = isOpen(value);
const context = {
value,
open,
disabled,
triggerId: `${accordionId}-trigger-${value}`,
contentId: `${accordionId}-content-${value}`,
toggle: () => {
if (!disabled) toggleItem(value);
}
};
return <AccordionItemContext.Provider value={context}>
<div
className={cx(
"border-b border-[var(--ds-color-border)] last:border-b-0",
className
)}
{...rest}
>
{children}
</div>
</AccordionItemContext.Provider>;
}
function AccordionTrigger({
icon,
badge,
description,
className,
children,
onClick,
type,
...rest
}) {
const { open, disabled, triggerId, contentId, toggle } = useAccordionItem("AccordionTrigger");
return <h3 className="m-0 flex">
<button
type={type ?? "button"}
id={triggerId}
aria-expanded={open}
aria-controls={contentId}
disabled={disabled}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) toggle();
}}
className={cx(TRIGGER_CLASSES, className)}
{...rest}
>
{icon != null && icon !== false ? <span
aria-hidden="true"
className="inline-flex size-4 shrink-0 items-center justify-center text-[var(--ds-color-muted-foreground)] [&_svg]:size-4"
>
{icon}
</span> : null}
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="break-words text-sm font-medium leading-5 text-[var(--ds-color-foreground)]">
{children}
</span>
{description != null && description !== false ? <span className="break-words text-[13px] font-normal leading-5 text-[var(--ds-color-muted-foreground)]">
{description}
</span> : null}
</span>
{badge != null && badge !== false ? <span className="inline-flex h-5 shrink-0 items-center justify-center rounded-[var(--ds-radius-full)] border border-[var(--ds-color-border)] bg-[var(--ds-color-surface-subtle)] px-1.5 text-[11px] font-medium leading-none text-[var(--ds-color-muted-foreground)]">
{badge}
</span> : null}
<span
aria-hidden="true"
className={cx(
"inline-flex size-4 shrink-0 items-center justify-center text-[var(--ds-color-muted-foreground)] transition-transform duration-200 ease-out motion-reduce:transition-none",
open && "rotate-180"
)}
>
{CHEVRON}
</span>
</button>
</h3>;
}
function AccordionContent({
className,
children,
...rest
}) {
const { open, triggerId, contentId } = useAccordionItem("AccordionContent");
return <div
id={contentId}
role="region"
aria-labelledby={triggerId}
className={cx(
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div
className={cx(
"min-h-0 overflow-hidden transition-[visibility] duration-200 motion-reduce:transition-none",
open ? "visible" : "invisible"
)}
>
<div
className={cx(
"break-words px-4 pb-4 text-sm leading-6 text-[var(--ds-color-muted-foreground)]",
className
)}
{...rest}
>
{children}
</div>
</div>
</div>;
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
export default Accordion; # FAQ Accordion
A realistic frequently-asked-questions accordion: question/answer pairs with roomier region padding, body-sm answer copy, and semantic question text in the trigger (the question IS the button label — no redundant ARIA question roles).
## Installation
This component requires **React** and **Tailwind CSS**. Drop `code.tsx` (or `code.jsx` for JavaScript projects) into your project. Tailwind utility classes are included directly in the component, so no separate CSS file is required.
The component consumes the DevSnips semantic design tokens through Tailwind arbitrary values (for example `bg-[var(--ds-color-surface)]`). Define the `--ds-*` tokens once in your theme — see [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the full token spec.
## Usage
```tsx
import Accordion, {
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "./accordion";
<Accordion type="multiple">
<AccordionItem value="billing">
<AccordionTrigger>How is metered usage billed?</AccordionTrigger>
<AccordionContent>…</AccordionContent>
</AccordionItem>
</Accordion>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Accordion, {
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "./accordion";
<Accordion type="multiple">
<AccordionItem value="billing">
<AccordionTrigger>How is metered usage billed?</AccordionTrigger>
<AccordionContent>…</AccordionContent>
</AccordionItem>
</Accordion>
```
## Props
### `<Accordion>`
| Name | Type | Default | Description |
|---|---|---|---|
| `type` | `"single" \| "multiple"` | `"single"` | Expansion mode: `single` keeps at most one item open; `multiple` lets any number stay open. |
| `collapsible` | `boolean` | `false` | Single mode only: allow the open item to be closed by activating its trigger again. When `false`, the open item's trigger is a no-op. Ignored in multiple mode. |
| `value` | single: `string \| null` · multiple: `string[]` | — | Controlled open value(s). The TypeScript type is discriminated by `type`, so it always matches the mode. |
| `defaultValue` | single: `string \| null` · multiple: `string[]` | — | Initial open value(s) when uncontrolled. |
| `onValueChange` | single: `(value: string \| null) => void` · multiple: `(value: string[]) => void` | — | Called with the next open value(s) whenever a trigger toggles an item. |
| `className` | `string` | — | Extra classes on the root div (for example the bordered-container treatment). |
| `children` | `ReactNode` | — | `AccordionItem` entries. |
Every other attribute of a plain `<div>` (`aria-*`, `data-*`, …) is forwarded to the root.
### `<AccordionItem>`
| Name | Type | Default | Description |
|---|---|---|---|
| `value` | `string` | required | Unique, id-safe identifier for the item within the accordion. It keys the open state and derives the trigger/region ids, so keep it stable and free of whitespace. |
| `disabled` | `boolean` | `false` | Disable the item: the trigger becomes natively `disabled` — unfocusable, not activatable, exposed as disabled to assistive technology. |
| `className` | `string` | — | Extra classes on the item div. |
| `children` | `ReactNode` | — | One `AccordionTrigger` + one `AccordionContent`. |
Every other attribute of a plain `<div>` is forwarded.
### `<AccordionTrigger>`
| Name | Type | Default | Description |
|---|---|---|---|
| `icon` | `ReactNode` | — | Leading visual affordance, rendered `aria-hidden` — it supplements the trigger text, never replaces it. |
| `badge` | `ReactNode` | — | Short status/count content rendered as a neutral pill before the chevron. It is plain text inside the button, so it joins the accessible name — keep it short and meaningful. |
| `description` | `ReactNode` | — | A short supporting line under the title. Also part of the accessible name — keep it brief. |
| `onClick` | `(event) => void` | — | Runs before the toggle; call `event.preventDefault()` to veto it. |
| `className` | `string` | — | Extra classes on the button. |
| `children` | `ReactNode` | — | The trigger title (required — it is the accessible name). |
The rendered element is a real `<button type="button">` wrapped in an `<h3>`; every other button attribute (`aria-*`, `data-*`, …) is forwarded. `disabled` is set on `<AccordionItem>`, not here.
### `<AccordionContent>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the inner content div (padding, typography overrides). |
| `children` | `ReactNode` | — | Region content — paragraphs, lists, real controls. |
The region element is `role="region"` with `aria-labelledby` pointing at its trigger; `className` and forwarded attributes (for example `aria-busy`) land on the inner content div. Content stays mounted while closed so state inside it survives a close/reopen cycle.
## Composition
- `Accordion` — the root provider: owns the open-item state (controlled `value` + `onValueChange`, or uncontrolled `defaultValue`), picks the expansion mode (`type="single"` default, `type="multiple"`), and derives the stable per-instance id base every trigger/region pair is built from.
- `AccordionItem` — one entry in the divided list (`border-b`, removed on the last item). Requires a unique id-safe `value`; computes its own open state from the root and derives the trigger/region ids.
- `AccordionTrigger` — the disclosure control: a real `<button type="button">` inside an `<h3>` heading, with `aria-expanded` + `aria-controls`, an optional leading `icon` (`aria-hidden`), an optional trailing `badge` pill, an optional `description` line, and the rotating chevron (also `aria-hidden`).
- `AccordionContent` — the collapsible region: `role="region"` labelled by its trigger, animated with the CSS grid-rows trick (no JavaScript measurement), hidden from the accessibility tree and tab order while closed.
Compose only what an entry needs — a bare trigger + content pair is valid; so is the full icon + description + badge composition.
FAQ content uses `type="multiple"` so readers can keep several answers open while comparing them, and a slightly roomier region (`pb-5`) since answers are read, not scanned. The question stays in the trigger — no heading levels are skipped because each trigger already sits in an `h3`.
## Behavior
An FAQ is the canonical accordion use case: the question is the trigger label, the answer is the region. This variant keeps the default treatment and adds only realistic support copy and roomier region padding — no special "FAQ styling" is invented, because the pattern is content, not chrome.
Multiple mode is used so a reader can open two answers side by side. Each trigger's `h3` wrapper keeps the questions in the page outline, which is exactly how users scan a FAQ with assistive technology: jump between headings, then activate the button for the full answer.
## Keyboard Interaction
Accordion triggers are real `<button type="button">` elements, so the keyboard model is the native button model: Tab moves focus through the triggers and through any interactive elements inside open regions, and Enter or Space toggles the focused item. Disabled triggers are natively `disabled`, so Tab skips them entirely and they cannot be activated by pointer or keyboard.
Arrow-key navigation and roving tabindex are deliberately NOT implemented: the WAI-ARIA accordion pattern marks them as optional, and triggers that behave like ordinary buttons keep Tab order predictable — every focusable element stays exactly one Tab stop.
## Accessibility
- Every trigger is a real `<button type="button">` with `aria-expanded` and `aria-controls` referencing its region's stable id; the region is `role="region"` with `aria-labelledby` pointing back at the trigger. Both ids derive from the accordion instance's `useId` base plus the item's `value`, so multiple accordions (including nested ones) never collide.
- The trigger sits inside an `<h3>` heading, so the accordion participates in the page outline.
- The leading `icon` and the trailing chevron are `aria-hidden="true"` — state is exposed by `aria-expanded`, never by the glyph. The `badge` pill is plain text inside the button and joins the accessible name; keep it short and meaningful (for example `"3 errors"`, not a bare `"!"`).
- The closed region uses the CSS `visibility` transition: while closed it is `visibility: hidden`, which removes it from the accessibility tree AND the tab order — collapsed content can never be announced or focused.
- Disabled items use the native `disabled` attribute: the state is exposed to assistive technology and the trigger leaves the tab order. No redundant `aria-disabled`.
- There are no ARIA roles for "question" and "answer" — the disclosure pattern (button + `aria-expanded` + `role="region"`) is the correct semantics. Adding more ARIA would only add noise.
## States
- **Trigger (idle)** — `color.foreground` title, `color.muted-foreground` icon/chevron; hover applies a `color.surface-hover` wash; keyboard focus shows a 2px `color.focus-ring` outline drawn inset (`-outline-offset-2`) so it is never clipped by bordered containers.
- **Trigger (open)** — the chevron rotates 180° over 200ms (`motion-reduce` makes the flip instant); `aria-expanded` flips with it. The state is also visible in the open region below, never carried by color alone.
- **Trigger (disabled)** — native `disabled`: 50% opacity, no pointer events, removed from the tab order.
- **Region** — height animates with the CSS grid-rows trick (`0fr` ↔ `1fr`, 200ms, `ease-out`), and a discrete `visibility` transition hides closed content from the accessibility tree and tab order once the collapse completes. Under `prefers-reduced-motion` every transition is removed and state changes are instant.
- **Region content** — body-sm on `color.muted-foreground`; mounted in both states so component state inside a region survives a close/reopen cycle.
## Responsive Behavior
The accordion is fluid-width (`w-full min-w-0`) and fills its container at every viewport. Trigger titles and descriptions wrap (`break-words`), the text column is `flex-1 min-w-0`, and the icon, badge, and chevron are `shrink-0` so they never push text off-screen. Region content wraps and long words break. No horizontal overflow at 375 / 768 / 1280px.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface)]`); no component-specific values are invented. Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This accordion follows the token system rules: 1px `color.border` dividers, `color.surface-hover` trigger feedback, body-sm text, the `color.focus-ring` token for keyboard focus (drawn inset so bordered containers never clip it), and `color.muted-foreground` for supporting text, icons, and badges.
## Notes
- Long answers belong in the region; if an answer needs structure, compose lists and links inside `AccordionContent` — it accepts any ReactNode.
- Every visual value comes from the `--ds-*` semantic tokens; light and dark themes flip through the same token block. No component-specific CSS file, no inline styles, no hardcoded hex. 403 lines UTF-8 · LF · Spaces: 2
Continue browsing