Skip to content

Commit eb303a6

Browse files
feat(headless): add Accordion primitive (#8475)
1 parent 032632c commit eb303a6

14 files changed

Lines changed: 946 additions & 1 deletion
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/headless/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
"sideEffects": false,
66
"type": "module",
77
"exports": {
8+
"./accordion": {
9+
"import": "./dist/primitives/accordion/index.js",
10+
"types": "./dist/primitives/accordion/index.d.ts"
11+
},
812
"./dialog": {
913
"import": "./dist/primitives/dialog/index.js",
1014
"types": "./dist/primitives/dialog/index.d.ts"
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Accordion
2+
3+
A vertically stacked set of collapsible sections. Supports single or multiple open panels, keyboard navigation, and CSS-driven expand/collapse animations.
4+
5+
## When to Use
6+
7+
- FAQ sections, settings panels, or any UI where content should be shown/hidden in discrete sections.
8+
- When you need accessible expand/collapse with proper ARIA attributes and keyboard support.
9+
- Prefer Accordion over manual show/hide toggles — it handles focus management, ARIA, and animation lifecycle automatically.
10+
11+
## Usage
12+
13+
```tsx
14+
import { Accordion } from '@/primitives/accordion';
15+
16+
<Accordion.Root
17+
type='single'
18+
defaultValue={['item-1']}
19+
>
20+
<Accordion.Item value='item-1'>
21+
<Accordion.Header>
22+
<Accordion.Trigger>Section 1</Accordion.Trigger>
23+
</Accordion.Header>
24+
<Accordion.Panel>Content for section 1</Accordion.Panel>
25+
</Accordion.Item>
26+
<Accordion.Item value='item-2'>
27+
<Accordion.Header>
28+
<Accordion.Trigger>Section 2</Accordion.Trigger>
29+
</Accordion.Header>
30+
<Accordion.Panel>Content for section 2</Accordion.Panel>
31+
</Accordion.Item>
32+
</Accordion.Root>;
33+
```
34+
35+
### Controlled
36+
37+
```tsx
38+
const [value, setValue] = useState<string[]>(['item-1']);
39+
40+
<Accordion.Root
41+
value={value}
42+
onValueChange={setValue}
43+
>
44+
{/* ... */}
45+
</Accordion.Root>;
46+
```
47+
48+
## Parts
49+
50+
| Part | Default Element | Description |
51+
| ------------------- | --------------- | ---------------------------------- |
52+
| `Accordion.Root` | `<div>` | Root wrapper, provides context |
53+
| `Accordion.Item` | `<div>` | Wraps a single collapsible section |
54+
| `Accordion.Header` | `<h3>` | Heading wrapper for the trigger |
55+
| `Accordion.Trigger` | `<button>` | Clickable toggle for its panel |
56+
| `Accordion.Panel` | `<div>` | Collapsible content area |
57+
58+
## Props
59+
60+
### `Accordion.Root`
61+
62+
| Prop | Type | Default | Description |
63+
| --------------- | --------------------------- | ------------ | ----------------------------------------- |
64+
| `value` | `string[]` || Controlled open items |
65+
| `defaultValue` | `string[]` | `[]` | Initial open items (uncontrolled) |
66+
| `onValueChange` | `(value: string[]) => void` || Called when open items change |
67+
| `type` | `"single" \| "multiple"` | `"multiple"` | `"single"` enforces at most one open item |
68+
| `disabled` | `boolean` | `false` | Disables all items |
69+
70+
### `Accordion.Item`
71+
72+
| Prop | Type | Default | Description |
73+
| ---------- | --------- | ------------- | ------------------------------- |
74+
| `value` | `string` | **required** | Unique identifier for this item |
75+
| `disabled` | `boolean` | inherits root | Disables this specific item |
76+
77+
### `Accordion.Header`
78+
79+
No additional props. Renders as `<h3>` by default.
80+
81+
### `Accordion.Trigger`
82+
83+
No additional props. Renders as `<button>` by default.
84+
85+
### `Accordion.Panel`
86+
87+
No additional props. Renders as `<div>` by default.
88+
89+
All parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their default element.
90+
91+
## Keyboard Navigation
92+
93+
| Key | Action |
94+
| ----------------- | ------------------------------ |
95+
| `ArrowDown` | Move focus to next trigger |
96+
| `ArrowUp` | Move focus to previous trigger |
97+
| `Enter` / `Space` | Toggle the focused item |
98+
99+
## Data Attributes
100+
101+
| Attribute | Applies To | Description |
102+
| ------------------ | -------------------- | ------------------------------------------------- |
103+
| `data-cl-slot` | All parts | Identifies each part (e.g. `"accordion-trigger"`) |
104+
| `data-cl-open` | Item, Trigger, Panel | Present when the item is expanded |
105+
| `data-cl-closed` | Item, Trigger, Panel | Present when the item is collapsed |
106+
| `data-cl-disabled` | Item, Trigger | Present when the item is disabled |
107+
108+
## CSS Animation
109+
110+
`Accordion.Panel` exposes a `--cl-accordion-panel-height` CSS custom property set to the panel's `scrollHeight` in pixels. Use this for height-based expand/collapse animations:
111+
112+
```css
113+
[data-cl-slot='accordion-panel'] {
114+
overflow: hidden;
115+
height: var(--cl-accordion-panel-height);
116+
transition: height 200ms ease;
117+
}
118+
[data-cl-slot='accordion-panel'][data-cl-closed] {
119+
height: 0;
120+
}
121+
```
122+
123+
The panel suppresses the enter animation on initial mount — only subsequent opens animate.
124+
125+
## ARIA
126+
127+
- Trigger: `aria-expanded`, `aria-controls` (pointing to its panel), `aria-disabled`
128+
- Panel: `role="region"`, `aria-labelledby` (pointing to its trigger)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createContext, useContext } from 'react';
2+
3+
export interface AccordionContextValue {
4+
value: string[];
5+
toggle: (itemValue: string) => void;
6+
disabled: boolean;
7+
accordionId: string;
8+
}
9+
10+
export const AccordionContext = createContext<AccordionContextValue | null>(null);
11+
12+
export function useAccordionContext() {
13+
const ctx = useContext(AccordionContext);
14+
if (!ctx) {
15+
throw new Error('Accordion compound components must be used within <Accordion.Root>');
16+
}
17+
return ctx;
18+
}
19+
20+
export interface AccordionItemContextValue {
21+
itemValue: string;
22+
open: boolean;
23+
disabled: boolean;
24+
triggerId: string;
25+
panelId: string;
26+
}
27+
28+
export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
29+
30+
export function useAccordionItemContext() {
31+
const ctx = useContext(AccordionItemContext);
32+
if (!ctx) {
33+
throw new Error('Accordion.Trigger/Header/Panel must be used within <Accordion.Item>');
34+
}
35+
return ctx;
36+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client';
2+
3+
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
4+
5+
export type AccordionHeaderProps = ComponentProps<'h3'>;
6+
7+
export function AccordionHeader(props: AccordionHeaderProps) {
8+
const { render, ...otherProps } = props;
9+
10+
const defaultProps = {
11+
'data-cl-slot': 'accordion-header',
12+
};
13+
14+
return renderElement({
15+
defaultTagName: 'h3',
16+
render,
17+
props: mergeProps<'h3'>(defaultProps, otherProps),
18+
});
19+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client';
2+
3+
import { useMemo } from 'react';
4+
5+
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
6+
import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';
7+
8+
export interface AccordionItemProps extends ComponentProps<'div'> {
9+
/** Unique value identifying this item. */
10+
value: string;
11+
/** Disable this specific item. */
12+
disabled?: boolean;
13+
}
14+
15+
export function AccordionItem(props: AccordionItemProps) {
16+
const { render, value: itemValue, disabled: itemDisabled, ...otherProps } = props;
17+
const ctx = useAccordionContext();
18+
19+
const open = ctx.value.includes(itemValue);
20+
const disabled = itemDisabled ?? ctx.disabled;
21+
const triggerId = `${ctx.accordionId}-trigger-${itemValue}`;
22+
const panelId = `${ctx.accordionId}-panel-${itemValue}`;
23+
24+
const itemContextValue = useMemo<AccordionItemContextValue>(
25+
() => ({ itemValue, open, disabled, triggerId, panelId }),
26+
[itemValue, open, disabled, triggerId, panelId],
27+
);
28+
29+
const state = { open, disabled };
30+
31+
const defaultProps = {
32+
'data-cl-slot': 'accordion-item',
33+
};
34+
35+
return (
36+
<AccordionItemContext.Provider value={itemContextValue}>
37+
{renderElement({
38+
defaultTagName: 'div',
39+
render,
40+
state,
41+
stateAttributesMapping: {
42+
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
43+
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
44+
},
45+
props: mergeProps<'div'>(defaultProps, otherProps),
46+
})}
47+
</AccordionItemContext.Provider>
48+
);
49+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use client';
2+
3+
import { useMergeRefs } from '@floating-ui/react';
4+
import { type RefObject, useLayoutEffect, useRef, useState } from 'react';
5+
6+
import { useTransition } from '../../hooks/use-transition';
7+
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
8+
import { useAccordionItemContext } from './accordion-context';
9+
10+
export type AccordionPanelProps = ComponentProps<'div'>;
11+
12+
export function AccordionPanel(props: AccordionPanelProps) {
13+
const { render, ref: consumerRef, ...otherProps } = props;
14+
const { open, triggerId, panelId } = useAccordionItemContext();
15+
16+
const panelRef = useRef<HTMLElement | null>(null);
17+
// Merge the consumer ref with the internal panelRef so passing a ref does not
18+
// clobber the ref the panel relies on for height measurement.
19+
const combinedRef = useMergeRefs([panelRef, consumerRef]);
20+
const [height, setHeight] = useState<number | undefined>(undefined);
21+
22+
// Track whether open has ever transitioned from true→false.
23+
// Until that happens, skip enter animations (prevents animate-on-load).
24+
const hasBeenClosed = useRef(false);
25+
if (!open) {
26+
hasBeenClosed.current = true;
27+
}
28+
29+
const { mounted, transitionProps } = useTransition({
30+
open,
31+
ref: panelRef as RefObject<HTMLElement>,
32+
});
33+
34+
// Measure the content height and keep it in sync via ResizeObserver
35+
useLayoutEffect(() => {
36+
if (!mounted) {
37+
return;
38+
}
39+
40+
const panel = panelRef.current;
41+
if (!panel) {
42+
return;
43+
}
44+
45+
// Measure scrollHeight of the panel's content
46+
const measure = () => {
47+
setHeight(panel.scrollHeight);
48+
};
49+
50+
measure();
51+
52+
const ro = new ResizeObserver(measure);
53+
// Observe children mutations that affect height
54+
ro.observe(panel, { box: 'border-box' });
55+
56+
return () => ro.disconnect();
57+
}, [mounted]);
58+
59+
const state = { open };
60+
61+
// Skip enter animation for panels that have never been closed
62+
const effectiveTransitionProps = !hasBeenClosed.current
63+
? {
64+
...transitionProps,
65+
'data-cl-starting-style': undefined,
66+
style: undefined,
67+
}
68+
: transitionProps;
69+
70+
const defaultProps: Record<string, unknown> = {
71+
'data-cl-slot': 'accordion-panel',
72+
id: panelId,
73+
role: 'region' as const,
74+
'aria-labelledby': triggerId,
75+
ref: combinedRef,
76+
...effectiveTransitionProps,
77+
style: {
78+
'--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
79+
...effectiveTransitionProps.style,
80+
},
81+
};
82+
83+
const merged = mergeProps<'div'>(defaultProps, otherProps);
84+
// The wired id is owned by the primitive: a consumer-supplied id must not
85+
// override it, or the trigger/panel aria pairing would silently break.
86+
merged.id = panelId;
87+
88+
return renderElement({
89+
defaultTagName: 'div',
90+
render,
91+
enabled: mounted,
92+
state,
93+
stateAttributesMapping: {
94+
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
95+
},
96+
props: merged,
97+
});
98+
}

0 commit comments

Comments
 (0)