` by default.
+
+All parts accept a `render` prop for polymorphic rendering and standard HTML attributes for their default element.
+
+## Keyboard Navigation
+
+| Key | Action |
+| ----------------- | ------------------------------ |
+| `ArrowDown` | Move focus to next trigger |
+| `ArrowUp` | Move focus to previous trigger |
+| `Enter` / `Space` | Toggle the focused item |
+
+## Data Attributes
+
+| Attribute | Applies To | Description |
+| ------------------ | -------------------- | ------------------------------------------------- |
+| `data-cl-slot` | All parts | Identifies each part (e.g. `"accordion-trigger"`) |
+| `data-cl-open` | Item, Trigger, Panel | Present when the item is expanded |
+| `data-cl-closed` | Item, Trigger, Panel | Present when the item is collapsed |
+| `data-cl-disabled` | Item, Trigger | Present when the item is disabled |
+
+## CSS Animation
+
+`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:
+
+```css
+[data-cl-slot='accordion-panel'] {
+ overflow: hidden;
+ height: var(--cl-accordion-panel-height);
+ transition: height 200ms ease;
+}
+[data-cl-slot='accordion-panel'][data-cl-closed] {
+ height: 0;
+}
+```
+
+The panel suppresses the enter animation on initial mount — only subsequent opens animate.
+
+## ARIA
+
+- Trigger: `aria-expanded`, `aria-controls` (pointing to its panel), `aria-disabled`
+- Panel: `role="region"`, `aria-labelledby` (pointing to its trigger)
diff --git a/packages/headless/src/primitives/accordion/accordion-context.ts b/packages/headless/src/primitives/accordion/accordion-context.ts
new file mode 100644
index 00000000000..60e01365261
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/accordion-context.ts
@@ -0,0 +1,36 @@
+import { createContext, useContext } from 'react';
+
+export interface AccordionContextValue {
+ value: string[];
+ toggle: (itemValue: string) => void;
+ disabled: boolean;
+ accordionId: string;
+}
+
+export const AccordionContext = createContext
(null);
+
+export function useAccordionContext() {
+ const ctx = useContext(AccordionContext);
+ if (!ctx) {
+ throw new Error('Accordion compound components must be used within ');
+ }
+ return ctx;
+}
+
+export interface AccordionItemContextValue {
+ itemValue: string;
+ open: boolean;
+ disabled: boolean;
+ triggerId: string;
+ panelId: string;
+}
+
+export const AccordionItemContext = createContext(null);
+
+export function useAccordionItemContext() {
+ const ctx = useContext(AccordionItemContext);
+ if (!ctx) {
+ throw new Error('Accordion.Trigger/Header/Panel must be used within ');
+ }
+ return ctx;
+}
diff --git a/packages/headless/src/primitives/accordion/accordion-header.tsx b/packages/headless/src/primitives/accordion/accordion-header.tsx
new file mode 100644
index 00000000000..4511e9d17a4
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/accordion-header.tsx
@@ -0,0 +1,19 @@
+'use client';
+
+import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
+
+export interface AccordionHeaderProps extends ComponentProps<'h3'> {}
+
+export function AccordionHeader(props: AccordionHeaderProps) {
+ const { render, ...otherProps } = props;
+
+ const defaultProps = {
+ 'data-cl-slot': 'accordion-header',
+ };
+
+ return renderElement({
+ defaultTagName: 'h3',
+ render,
+ props: mergeProps<'h3'>(defaultProps, otherProps),
+ });
+}
diff --git a/packages/headless/src/primitives/accordion/accordion-item.tsx b/packages/headless/src/primitives/accordion/accordion-item.tsx
new file mode 100644
index 00000000000..02c14b28be3
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/accordion-item.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { useMemo } from 'react';
+import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
+import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context';
+
+export interface AccordionItemProps extends ComponentProps<'div'> {
+ /** Unique value identifying this item. */
+ value: string;
+ /** Disable this specific item. */
+ disabled?: boolean;
+}
+
+export function AccordionItem(props: AccordionItemProps) {
+ const { render, value: itemValue, disabled: itemDisabled, ...otherProps } = props;
+ const ctx = useAccordionContext();
+
+ const open = ctx.value.includes(itemValue);
+ const disabled = itemDisabled ?? ctx.disabled;
+ const triggerId = `${ctx.accordionId}-trigger-${itemValue}`;
+ const panelId = `${ctx.accordionId}-panel-${itemValue}`;
+
+ const itemContextValue = useMemo(
+ () => ({ itemValue, open, disabled, triggerId, panelId }),
+ [itemValue, open, disabled, triggerId, panelId],
+ );
+
+ const state = { open, disabled };
+
+ const defaultProps = {
+ 'data-cl-slot': 'accordion-item',
+ };
+
+ return (
+
+ {renderElement({
+ defaultTagName: 'div',
+ render,
+ state,
+ stateAttributesMapping: {
+ open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
+ disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
+ },
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ })}
+
+ );
+}
diff --git a/packages/headless/src/primitives/accordion/accordion-panel.tsx b/packages/headless/src/primitives/accordion/accordion-panel.tsx
new file mode 100644
index 00000000000..c7d79529dac
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/accordion-panel.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { type RefObject, useLayoutEffect, useRef, useState } from 'react';
+import { useTransition } from '../../hooks/use-transition';
+import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
+import { useAccordionItemContext } from './accordion-context';
+
+export interface AccordionPanelProps extends ComponentProps<'div'> {}
+
+export function AccordionPanel(props: AccordionPanelProps) {
+ const { render, ...otherProps } = props;
+ const { open, triggerId, panelId } = useAccordionItemContext();
+
+ const panelRef = useRef(null);
+ const [height, setHeight] = useState(undefined);
+
+ // Track whether open has ever transitioned from true→false.
+ // Until that happens, skip enter animations (prevents animate-on-load).
+ const hasBeenClosed = useRef(false);
+ if (!open) hasBeenClosed.current = true;
+
+ const { mounted, transitionProps } = useTransition({
+ open,
+ ref: panelRef as RefObject,
+ });
+
+ // Measure the content height and keep it in sync via ResizeObserver
+ useLayoutEffect(() => {
+ if (!mounted) return;
+
+ const panel = panelRef.current;
+ if (!panel) return;
+
+ // Measure scrollHeight of the panel's content
+ const measure = () => {
+ setHeight(panel.scrollHeight);
+ };
+
+ measure();
+
+ const ro = new ResizeObserver(measure);
+ // Observe children mutations that affect height
+ ro.observe(panel, { box: 'border-box' });
+
+ return () => ro.disconnect();
+ }, [mounted]);
+
+ const state = { open };
+
+ // Skip enter animation for panels that have never been closed
+ const effectiveTransitionProps = !hasBeenClosed.current
+ ? {
+ ...transitionProps,
+ 'data-cl-starting-style': undefined,
+ style: undefined,
+ }
+ : transitionProps;
+
+ const defaultProps: Record = {
+ 'data-cl-slot': 'accordion-panel',
+ id: panelId,
+ role: 'region' as const,
+ 'aria-labelledby': triggerId,
+ ref: panelRef,
+ ...effectiveTransitionProps,
+ style: {
+ '--cl-accordion-panel-height': height != null ? `${height}px` : undefined,
+ ...effectiveTransitionProps.style,
+ },
+ };
+
+ return renderElement({
+ defaultTagName: 'div',
+ render,
+ enabled: mounted,
+ state,
+ stateAttributesMapping: {
+ open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
+ },
+ props: mergeProps<'div'>(defaultProps, otherProps),
+ });
+}
diff --git a/packages/headless/src/primitives/accordion/accordion-root.tsx b/packages/headless/src/primitives/accordion/accordion-root.tsx
new file mode 100644
index 00000000000..942e38f8431
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/accordion-root.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { Composite } from '@floating-ui/react';
+import React, { type ReactNode, useCallback, useId, useMemo } from 'react';
+import { useControllableState } from '../../hooks/use-controllable-state';
+import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
+import { AccordionContext, type AccordionContextValue } from './accordion-context';
+
+export interface AccordionProps extends ComponentProps<'div'> {
+ /** Controlled open items. */
+ value?: string[];
+ /** Initial open items (uncontrolled). */
+ defaultValue?: string[];
+ /** Called when open items change. */
+ onValueChange?: (value: string[]) => void;
+ /** When true, only one item can be open at a time. @default false */
+ type?: 'single' | 'multiple';
+ /** Disable all items. @default false */
+ disabled?: boolean;
+ children: ReactNode;
+}
+
+export function AccordionRoot(props: AccordionProps) {
+ const { render, type = 'multiple', disabled = false, children, ...otherProps } = props;
+
+ const [value, setValue] = useControllableState(props.value, props.defaultValue ?? [], props.onValueChange);
+
+ const accordionId = useId();
+
+ const toggle = useCallback(
+ (itemValue: string) => {
+ const isOpen = value.includes(itemValue);
+ if (isOpen) {
+ setValue(value.filter(v => v !== itemValue));
+ } else if (type === 'single') {
+ setValue([itemValue]);
+ } else {
+ setValue([...value, itemValue]);
+ }
+ },
+ [type, value, setValue],
+ );
+
+ const contextValue = useMemo(
+ () => ({ value, toggle, disabled, accordionId }),
+ [value, toggle, disabled, accordionId],
+ );
+
+ return (
+
+ ) => {
+ // aria-orientation is injected by Composite but is not valid on a
+ // generic (no widget role). Strip it to avoid an axe violation.
+ const { 'aria-orientation': _ao, ...restCompositeProps } = compositeProps;
+
+ const defaultProps: Record
= {
+ 'data-cl-slot': 'accordion-root',
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (event.key !== 'Home' && event.key !== 'End') return;
+ event.preventDefault();
+ const items = Array.from(
+ event.currentTarget.querySelectorAll('[data-cl-slot="accordion-trigger"]:not([disabled])'),
+ );
+ if (items.length === 0) return;
+ const target = event.key === 'Home' ? items[0] : items[items.length - 1];
+ target.focus();
+ },
+ };
+
+ const merged = mergeProps<'div'>(
+ defaultProps,
+ mergeProps<'div'>(otherProps, restCompositeProps as Record),
+ );
+
+ return renderElement({
+ defaultTagName: 'div',
+ render,
+ props: merged,
+ })!;
+ }}
+ >
+ {children}
+
+
+ );
+}
diff --git a/packages/headless/src/primitives/accordion/accordion-trigger.tsx b/packages/headless/src/primitives/accordion/accordion-trigger.tsx
new file mode 100644
index 00000000000..5ad4f1896f7
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/accordion-trigger.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { CompositeItem } from '@floating-ui/react';
+import React from 'react';
+import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
+import { useAccordionContext, useAccordionItemContext } from './accordion-context';
+
+export interface AccordionTriggerProps extends ComponentProps<'button'> {}
+
+export function AccordionTrigger(props: AccordionTriggerProps) {
+ const { render, children, ...otherProps } = props;
+ const ctx = useAccordionContext();
+ const { itemValue, open, disabled, triggerId, panelId } = useAccordionItemContext();
+
+ const state = { open, disabled };
+
+ return (
+ ) => {
+ const defaultProps: Record = {
+ 'data-cl-slot': 'accordion-trigger',
+ id: triggerId,
+ type: 'button' as const,
+ 'aria-expanded': open,
+ 'aria-controls': panelId,
+ 'aria-disabled': disabled || undefined,
+ onClick: () => {
+ if (!disabled) ctx.toggle(itemValue);
+ },
+ };
+
+ const merged = mergeProps<'button'>(
+ mergeProps<'button'>(defaultProps, otherProps),
+ compositeProps as Record,
+ );
+
+ return renderElement({
+ defaultTagName: 'button',
+ render,
+ state,
+ stateAttributesMapping: {
+ open: (v: boolean): Record | null =>
+ v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
+ disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
+ },
+ props: merged,
+ })!;
+ }}
+ >
+ {children}
+
+ );
+}
diff --git a/packages/headless/src/primitives/accordion/accordion.test.tsx b/packages/headless/src/primitives/accordion/accordion.test.tsx
new file mode 100644
index 00000000000..3aa4f3d7db2
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/accordion.test.tsx
@@ -0,0 +1,419 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { axe } from '../../test-utils/axe';
+import { Accordion } from './index';
+
+afterEach(() => cleanup());
+
+function renderAccordion(props: Partial> = {}) {
+ return render(
+
+
+
+ Section 1
+
+ Content 1
+
+
+
+ Section 2
+
+ Content 2
+
+
+
+ Section 3
+
+ Content 3
+
+ ,
+ );
+}
+
+describe('Accordion', () => {
+ describe('slot attributes', () => {
+ it('renders root with data-cl-slot', () => {
+ renderAccordion();
+ expect(document.querySelector('[data-cl-slot="accordion-root"]')).toBeInTheDocument();
+ });
+
+ it('renders items with data-cl-slot', () => {
+ renderAccordion();
+ const items = document.querySelectorAll('[data-cl-slot="accordion-item"]');
+ expect(items).toHaveLength(3);
+ });
+
+ it('renders headers with data-cl-slot', () => {
+ renderAccordion();
+ const headers = document.querySelectorAll('[data-cl-slot="accordion-header"]');
+ expect(headers).toHaveLength(3);
+ });
+
+ it('renders triggers with data-cl-slot', () => {
+ renderAccordion();
+ const triggers = document.querySelectorAll('[data-cl-slot="accordion-trigger"]');
+ expect(triggers).toHaveLength(3);
+ });
+
+ it('renders panels with data-cl-slot when open', () => {
+ renderAccordion({ defaultValue: ['item1'] });
+ expect(document.querySelector('[data-cl-slot="accordion-panel"]')).toBeInTheDocument();
+ });
+ });
+
+ describe('expand/collapse', () => {
+ it('opens an item on trigger click', async () => {
+ const user = userEvent.setup();
+ renderAccordion();
+
+ await user.click(screen.getByRole('button', { name: 'Section 1' }));
+
+ const item = document.querySelectorAll('[data-cl-slot="accordion-item"]')[0];
+ expect(item).toHaveAttribute('data-cl-open', '');
+ expect(screen.getByText('Content 1')).toBeInTheDocument();
+ });
+
+ it('closes an open item on trigger click', async () => {
+ const user = userEvent.setup();
+ renderAccordion({ defaultValue: ['item1'] });
+
+ await user.click(screen.getByRole('button', { name: 'Section 1' }));
+
+ const item = document.querySelectorAll('[data-cl-slot="accordion-item"]')[0];
+ expect(item).toHaveAttribute('data-cl-closed', '');
+ });
+
+ it('calls onValueChange when toggled', async () => {
+ const onValueChange = vi.fn();
+ const user = userEvent.setup();
+ renderAccordion({ onValueChange });
+
+ await user.click(screen.getByRole('button', { name: 'Section 1' }));
+
+ expect(onValueChange).toHaveBeenCalledWith(['item1']);
+ });
+
+ it('allows multiple items open in multiple mode', async () => {
+ const user = userEvent.setup();
+ renderAccordion({ type: 'multiple' });
+
+ await user.click(screen.getByRole('button', { name: 'Section 1' }));
+ await user.click(screen.getByRole('button', { name: 'Section 2' }));
+
+ expect(screen.getByText('Content 1')).toBeInTheDocument();
+ expect(screen.getByText('Content 2')).toBeInTheDocument();
+ });
+
+ it('allows only one item open in single mode', async () => {
+ const user = userEvent.setup();
+ renderAccordion({ type: 'single' });
+
+ await user.click(screen.getByRole('button', { name: 'Section 1' }));
+ await user.click(screen.getByRole('button', { name: 'Section 2' }));
+
+ expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
+ expect(screen.getByText('Content 2')).toBeInTheDocument();
+ });
+ });
+
+ describe('controlled value', () => {
+ it('respects controlled value prop', () => {
+ renderAccordion({ value: ['item2'] });
+
+ expect(screen.getByText('Content 2')).toBeInTheDocument();
+ expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
+ });
+
+ it('does not change when controlled', async () => {
+ const user = userEvent.setup();
+ renderAccordion({ value: [] });
+
+ await user.click(screen.getByRole('button', { name: 'Section 1' }));
+
+ expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('ARIA attributes', () => {
+ it('trigger has aria-expanded=false when closed', () => {
+ renderAccordion();
+ const trigger = screen.getByRole('button', { name: 'Section 1' });
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('trigger has aria-expanded=true when open', () => {
+ renderAccordion({ defaultValue: ['item1'] });
+ const trigger = screen.getByRole('button', { name: 'Section 1' });
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('trigger has aria-controls linked to panel id', () => {
+ renderAccordion({ defaultValue: ['item1'] });
+ const trigger = screen.getByRole('button', { name: 'Section 1' });
+ const panel = document.querySelector('[data-cl-slot="accordion-panel"]');
+
+ expect(trigger).toHaveAttribute('aria-controls', panel?.getAttribute('id'));
+ });
+
+ it('panel has aria-labelledby linked to trigger id', () => {
+ renderAccordion({ defaultValue: ['item1'] });
+ const trigger = screen.getByRole('button', { name: 'Section 1' });
+ const panel = document.querySelector('[data-cl-slot="accordion-panel"]');
+
+ expect(panel).toHaveAttribute('aria-labelledby', trigger.getAttribute('id'));
+ });
+
+ it('panel has role=region', () => {
+ renderAccordion({ defaultValue: ['item1'] });
+ expect(screen.getByRole('region')).toBeInTheDocument();
+ });
+ });
+
+ describe('animation lifecycle', () => {
+ it('panel is not in DOM when closed', () => {
+ renderAccordion();
+ expect(document.querySelector('[data-cl-slot="accordion-panel"]')).not.toBeInTheDocument();
+ });
+
+ it('applies data-cl-open on panel when open', () => {
+ renderAccordion({ defaultValue: ['item1'] });
+ const panel = document.querySelector('[data-cl-slot="accordion-panel"]');
+ expect(panel).toHaveAttribute('data-cl-open', '');
+ });
+
+ it('does not apply starting-style on initially open panels', () => {
+ renderAccordion({ defaultValue: ['item1'] });
+ const panel = document.querySelector('[data-cl-slot="accordion-panel"]');
+ expect(panel).not.toHaveAttribute('data-cl-starting-style');
+ });
+
+ it('sets --cl-accordion-panel-height CSS variable on panel', () => {
+ renderAccordion({ defaultValue: ['item1'] });
+ const panel = document.querySelector('[data-cl-slot="accordion-panel"]') as HTMLElement;
+ // Verify the CSS variable is present in the style attribute
+ expect(panel.getAttribute('style')).toContain('--cl-accordion-panel-height');
+ });
+ });
+
+ describe('disabled', () => {
+ it('disables all items when disabled on root', () => {
+ renderAccordion({ disabled: true });
+ const triggers = screen.getAllByRole('button');
+ triggers.forEach(trigger => {
+ expect(trigger).toHaveAttribute('aria-disabled', 'true');
+ });
+ });
+
+ it('prevents toggle when disabled', async () => {
+ const onValueChange = vi.fn();
+ const user = userEvent.setup();
+ renderAccordion({ disabled: true, onValueChange });
+
+ await user.click(screen.getByRole('button', { name: 'Section 1' }));
+
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('disables individual item', async () => {
+ const onValueChange = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+
+
+ Disabled
+
+ Content
+
+
+
+ Enabled
+
+ Content 2
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Disabled' }));
+ expect(onValueChange).not.toHaveBeenCalled();
+
+ await user.click(screen.getByRole('button', { name: 'Enabled' }));
+ expect(onValueChange).toHaveBeenCalledWith(['item2']);
+ });
+
+ it('applies data-cl-disabled on item and trigger', () => {
+ render(
+
+
+
+ Disabled
+
+ Content
+
+ ,
+ );
+
+ const item = document.querySelector('[data-cl-slot="accordion-item"]');
+ const trigger = document.querySelector('[data-cl-slot="accordion-trigger"]');
+ expect(item).toHaveAttribute('data-cl-disabled', '');
+ expect(trigger).toHaveAttribute('data-cl-disabled', '');
+ });
+ });
+
+ describe('keyboard navigation', () => {
+ it('moves focus down with ArrowDown', async () => {
+ const user = userEvent.setup();
+ renderAccordion();
+
+ const triggers = screen.getAllByRole('button');
+ triggers[0].focus();
+ await user.keyboard('{ArrowDown}');
+
+ expect(triggers[1]).toHaveFocus();
+ });
+
+ it('moves focus up with ArrowUp', async () => {
+ const user = userEvent.setup();
+ renderAccordion();
+
+ const triggers = screen.getAllByRole('button');
+ triggers[1].focus();
+ await user.keyboard('{ArrowUp}');
+
+ expect(triggers[0]).toHaveFocus();
+ });
+
+ it('toggles item with Enter', async () => {
+ const user = userEvent.setup();
+ renderAccordion();
+
+ const trigger = screen.getByRole('button', { name: 'Section 1' });
+ trigger.focus();
+ await user.keyboard('{Enter}');
+
+ expect(screen.getByText('Content 1')).toBeInTheDocument();
+ });
+
+ it('toggles item with Space', async () => {
+ const user = userEvent.setup();
+ renderAccordion();
+
+ const trigger = screen.getByRole('button', { name: 'Section 1' });
+ trigger.focus();
+ await user.keyboard(' ');
+
+ expect(screen.getByText('Content 1')).toBeInTheDocument();
+ });
+
+ it('moves focus to first trigger with Home', async () => {
+ const user = userEvent.setup();
+ renderAccordion();
+
+ const triggers = screen.getAllByRole('button');
+ triggers[2].focus();
+ await user.keyboard('{Home}');
+
+ expect(triggers[0]).toHaveFocus();
+ });
+
+ it('moves focus to last trigger with End', async () => {
+ const user = userEvent.setup();
+ renderAccordion();
+
+ const triggers = screen.getAllByRole('button');
+ triggers[0].focus();
+ await user.keyboard('{End}');
+
+ expect(triggers[2]).toHaveFocus();
+ });
+
+ it('Home skips disabled triggers', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+ Section 1
+
+ Content 1
+
+
+
+ Section 2
+
+ Content 2
+
+
+
+ Section 3
+
+ Content 3
+
+ ,
+ );
+
+ const section3 = screen.getByRole('button', { name: 'Section 3' });
+ section3.focus();
+ await user.keyboard('{Home}');
+
+ expect(screen.getByRole('button', { name: 'Section 2' })).toHaveFocus();
+ });
+
+ it('End skips disabled triggers', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+ Section 1
+
+ Content 1
+
+
+
+ Section 2
+
+ Content 2
+
+
+
+ Section 3
+
+ Content 3
+
+ ,
+ );
+
+ const section1 = screen.getByRole('button', { name: 'Section 1' });
+ section1.focus();
+ await user.keyboard('{End}');
+
+ expect(screen.getByRole('button', { name: 'Section 2' })).toHaveFocus();
+ });
+ });
+
+ describe('accessibility (axe)', () => {
+ it('has no violations when collapsed', async () => {
+ const { container } = renderAccordion();
+ expect(await axe(container)).toHaveNoViolations();
+ });
+
+ it('has no violations when expanded', async () => {
+ const { container } = renderAccordion({ defaultValue: ['item1'] });
+ expect(await axe(container)).toHaveNoViolations();
+ });
+ });
+});
diff --git a/packages/headless/src/primitives/accordion/index.ts b/packages/headless/src/primitives/accordion/index.ts
new file mode 100644
index 00000000000..a90c147aad6
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/index.ts
@@ -0,0 +1,9 @@
+export * as Accordion from './parts';
+
+export type {
+ AccordionHeaderProps,
+ AccordionItemProps,
+ AccordionPanelProps,
+ AccordionProps,
+ AccordionTriggerProps,
+} from './parts';
diff --git a/packages/headless/src/primitives/accordion/parts.ts b/packages/headless/src/primitives/accordion/parts.ts
new file mode 100644
index 00000000000..83f7edb88dc
--- /dev/null
+++ b/packages/headless/src/primitives/accordion/parts.ts
@@ -0,0 +1,5 @@
+export { type AccordionProps, AccordionRoot as Root } from './accordion-root';
+export { type AccordionItemProps, AccordionItem as Item } from './accordion-item';
+export { type AccordionHeaderProps, AccordionHeader as Header } from './accordion-header';
+export { type AccordionTriggerProps, AccordionTrigger as Trigger } from './accordion-trigger';
+export { type AccordionPanelProps, AccordionPanel as Panel } from './accordion-panel';
diff --git a/packages/headless/src/primitives/dialog/dialog.tsx b/packages/headless/src/primitives/dialog/dialog.tsx
new file mode 100644
index 00000000000..2a54aed0a57
--- /dev/null
+++ b/packages/headless/src/primitives/dialog/dialog.tsx
@@ -0,0 +1,377 @@
+'use client';
+
+import {
+ type ExtendedRefs,
+ type FloatingContext,
+ FloatingFocusManager,
+ FloatingNode,
+ FloatingOverlay,
+ FloatingPortal,
+ FloatingTree,
+ type ReferenceType,
+ type UseInteractionsReturn,
+ useClick,
+ useDismiss,
+ useFloating,
+ useFloatingNodeId,
+ useFloatingParentNodeId,
+ useInteractions,
+ useMergeRefs,
+ useRole,
+} from '@floating-ui/react';
+import { createContext, type ReactNode, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useControllableState } from '../../hooks/use-controllable-state';
+import { type TransitionProps, useTransition } from '../../hooks/use-transition';
+import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
+
+// ---------------------------------------------------------------------------
+// Context
+// ---------------------------------------------------------------------------
+
+interface DialogContextValue {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ floatingContext: FloatingContext;
+ refs: ExtendedRefs;
+ getReferenceProps: UseInteractionsReturn['getReferenceProps'];
+ getFloatingProps: UseInteractionsReturn['getFloatingProps'];
+ popupRef: React.RefObject;
+ modal: boolean;
+ labelId: string | undefined;
+ descriptionId: string | undefined;
+ setLabelId: React.Dispatch>;
+ setDescriptionId: React.Dispatch>;
+ mounted: boolean;
+ transitionProps: TransitionProps;
+}
+
+const DialogContext = createContext(null);
+const DialogScopedContext = createContext(false);
+
+function useDialogContext() {
+ const ctx = useContext(DialogContext);
+ if (!ctx) {
+ throw new Error('Dialog compound components must be used within