A stack of headings that each show or hide a section of content.
View codeHide code
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
AccordionTrigger
} from '@syncclouds/ui'
const QUESTIONS = [
{
value: 'accessible',
question: 'Is it accessible?',
answer:
'Yes. It follows the WAI-ARIA Accordion pattern, and the arrow keys move between headers.'
},
{
value: 'styled',
question: 'Is it styled?',
answer:
'Only if you import the optional theme. Without it, the parts render plain elements you style yourself.'
},
{
value: 'multiple',
question: 'Can more than one item open?',
answer: 'Yes. Set type to multiple and each item opens on its own.'
}
]
export function AccordionDemo() {
return (
<Accordion defaultValue={['accessible']} className="w-full max-w-md">
{QUESTIONS.map(item => (
<AccordionItem key={item.value} value={item.value}>
<AccordionHeader>
<AccordionTrigger>{item.question}</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>{item.answer}</AccordionPanel>
</AccordionItem>
))}
</Accordion>
)
}Installation
pnpm add @syncclouds/uinpm install @syncclouds/uiyarn add @syncclouds/uibun add @syncclouds/uipnpm dlx shadcn@latest add https://ui.syncclouds.com/r/accordion.jsonnpx shadcn@latest add https://ui.syncclouds.com/r/accordion.jsonyarn dlx shadcn@latest add https://ui.syncclouds.com/r/accordion.jsonbunx --bun shadcn@latest add https://ui.syncclouds.com/r/accordion.jsonThe CLI copies 5 files into your ui folder.
1. Copy these files into one folder
accordion.tsxShowHide
'use client'
import {
createContext,
use,
useId,
type ComponentPropsWithRef,
type KeyboardEvent
} from 'react'
import { Slot } from './slot'
import { useControllableState } from './sui-use-controllable-state'
import { nextIndex, ownItems } from './sui-keyboard'
interface AccordionContextValue {
open: string[]
toggle: (value: string) => void
}
interface ItemContextValue {
open: boolean
disabled: boolean
triggerId: string
panelId: string
value: string
}
const AccordionContext = createContext<AccordionContextValue | null>(null)
const ItemContext = createContext<ItemContextValue | null>(null)
const useAccordion = (part: string) => {
const context = use(AccordionContext)
if (!context) throw new Error(`<${part}> must be used inside <Accordion>`)
return context
}
const useItem = (part: string) => {
const context = use(ItemContext)
if (!context) throw new Error(`<${part}> must be used inside <AccordionItem>`)
return context
}
export interface AccordionProps
extends Omit<ComponentPropsWithRef<'div'>, 'defaultValue' | 'onChange'> {
/** single: opening one item closes the others. multiple: items are independent. */
type?: 'single' | 'multiple'
value?: string[]
defaultValue?: string[]
onValueChange?: (value: string[]) => void
}
/**
* An accordion (APG Accordion). Each header holds a button that shows or
* hides its panel. Up, Down, Home and End move between headers.
*/
export function Accordion({
type = 'single',
value,
defaultValue = [],
onValueChange,
...props
}: AccordionProps) {
const [open, setOpen] = useControllableState({
prop: value,
defaultProp: defaultValue,
onChange: onValueChange
})
const toggle = (item: string) => {
if (open.includes(item)) setOpen(open.filter(v => v !== item))
else setOpen(type === 'single' ? [item] : [...open, item])
}
return (
<AccordionContext value={{ open, toggle }}>
<div {...props} data-scope="accordion" data-part="root" />
</AccordionContext>
)
}
export interface AccordionItemProps extends ComponentPropsWithRef<'div'> {
value: string
disabled?: boolean
}
export function AccordionItem({
value,
disabled = false,
...props
}: AccordionItemProps) {
const { open } = useAccordion('AccordionItem')
const id = useId()
const isOpen = open.includes(value)
return (
<ItemContext
value={{
open: isOpen,
disabled,
triggerId: `${id}-trigger`,
panelId: `${id}-panel`,
value
}}
>
<div
{...props}
data-scope="accordion"
data-part="item"
data-state={isOpen ? 'open' : 'closed'}
data-disabled={disabled ? '' : undefined}
/>
</ItemContext>
)
}
/** An h3 by default. Use asChild to render another heading level. */
export function AccordionHeader({
asChild,
...props
}: ComponentPropsWithRef<'h3'> & { asChild?: boolean }) {
const { open } = useItem('AccordionHeader')
const Comp = asChild ? Slot : 'h3'
return (
<Comp
{...props}
data-scope="accordion"
data-part="header"
data-state={open ? 'open' : 'closed'}
/>
)
}
export function AccordionTrigger({
onClick,
onKeyDown,
...props
}: ComponentPropsWithRef<'button'>) {
const { toggle } = useAccordion('AccordionTrigger')
const { open, disabled, triggerId, panelId, value } = useItem('AccordionTrigger')
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
const root = event.currentTarget.closest<HTMLElement>(
'[data-scope="accordion"][data-part="root"]'
)
if (!root) return
const triggers = ownItems(
root,
'[data-scope="accordion"][data-part="trigger"]',
'[data-scope="accordion"][data-part="root"]'
)
const next = nextIndex(
event.key,
triggers.indexOf(event.currentTarget),
triggers.length,
'vertical'
)
if (next === null) return
event.preventDefault()
triggers[next].focus()
}
return (
<button
type="button"
{...props}
id={triggerId}
aria-expanded={open}
aria-controls={panelId}
disabled={disabled}
data-scope="accordion"
data-part="trigger"
data-state={open ? 'open' : 'closed'}
data-disabled={disabled ? '' : undefined}
onClick={event => {
onClick?.(event)
if (!event.defaultPrevented) toggle(value)
}}
onKeyDown={handleKeyDown}
/>
)
}
export function AccordionPanel(props: ComponentPropsWithRef<'div'>) {
const { open, triggerId, panelId } = useItem('AccordionPanel')
return (
<div
{...props}
role="region"
id={panelId}
aria-labelledby={triggerId}
hidden={!open}
data-scope="accordion"
data-part="panel"
data-state={open ? 'open' : 'closed'}
/>
)
}
slot.tsxShowHide
import {
Children,
cloneElement,
isValidElement,
type CSSProperties,
type ReactElement,
type ReactNode,
type Ref
} from 'react'
import { composeRefs } from './sui-refs'
type AnyProps = Record<string, unknown>
/**
* Renders its only child with the slot's props merged in, so any component
* can take over the element (`asChild`). Event handlers run child first,
* then the slot's, unless the child's handler called preventDefault.
* Class names join, styles merge, refs compose. No hooks, so it works in
* server components too.
*/
export function Slot({
children,
...slotProps
}: AnyProps & { children?: ReactNode; ref?: Ref<unknown> }) {
const child = Children.only(children)
if (!isValidElement(child)) return null
const element = child as ReactElement<AnyProps>
const childProps = element.props
const merged: AnyProps = { ...slotProps, ...childProps }
for (const key of Object.keys(slotProps)) {
const slotValue = slotProps[key]
const childValue = childProps[key]
if (/^on[A-Z]/.test(key) && typeof slotValue === 'function') {
merged[key] =
typeof childValue === 'function'
? (...args: unknown[]) => {
childValue(...args)
const event = args[0] as { defaultPrevented?: boolean } | undefined
if (!event?.defaultPrevented) slotValue(...args)
}
: slotValue
} else if (key === 'className') {
merged.className = [slotValue, childValue].filter(Boolean).join(' ')
} else if (key === 'style') {
merged.style = {
...(slotValue as CSSProperties),
...(childValue as CSSProperties)
}
}
}
/* Only touch the ref when there is one: a server component rendering
<Slot><Link /></Slot> must not hand a function ref to a client child. */
const slotRef = slotProps.ref as Ref<unknown> | undefined
const childRef = childProps.ref as Ref<unknown> | undefined
if (slotRef && childRef) merged.ref = composeRefs(slotRef, childRef)
else if (slotRef || childRef) merged.ref = slotRef ?? childRef
else delete merged.ref
return cloneElement(element, merged)
}
sui-refs.tsShowHide
import { useCallback, type Ref, type RefCallback } from 'react'
const setRef = <T,>(ref: Ref<T> | undefined, value: T | null) => {
if (typeof ref === 'function') ref(value)
else if (ref) ref.current = value
}
/** One callback ref that feeds several refs. */
export const composeRefs =
<T,>(...refs: (Ref<T> | undefined)[]): RefCallback<T> =>
node => {
for (const ref of refs) setRef(ref, node)
}
/** A user's ref and a component's own ref, as one stable callback ref. */
export const useComposedRefs = <T,>(
outer: Ref<T> | undefined,
inner: Ref<T> | undefined
) => useCallback((node: T | null) => composeRefs(outer, inner)(node), [outer, inner])
sui-use-controllable-state.tsShowHide
import { useCallback, useState } from 'react'
/**
* Controlled when `prop` is defined, uncontrolled otherwise. `onChange`
* fires for every change either way.
*/
export function useControllableState<T>({
prop,
defaultProp,
onChange
}: {
prop: T | undefined
defaultProp: T
onChange?: (value: T) => void
}) {
const [uncontrolled, setUncontrolled] = useState(defaultProp)
const controlled = prop !== undefined
const value = controlled ? prop : uncontrolled
const setValue = useCallback(
(next: T) => {
if (!controlled) setUncontrolled(next)
if (!Object.is(next, value)) onChange?.(next)
},
[controlled, onChange, value]
)
return [value, setValue] as const
}
sui-keyboard.tsShowHide
import { useRef } from 'react'
export type Orientation = 'horizontal' | 'vertical'
/**
* The next index for a list navigated with arrow keys, Home and End, or
* null when the key is not a navigation key. Lists wrap, as the APG
* patterns used here recommend. `both` accepts all four arrows (radio).
*/
export function nextIndex(
key: string,
current: number,
count: number,
orientation: Orientation | 'both'
): number | null {
if (count === 0) return null
const forward =
(orientation !== 'horizontal' && key === 'ArrowDown') ||
(orientation !== 'vertical' && key === 'ArrowRight')
const backward =
(orientation !== 'horizontal' && key === 'ArrowUp') ||
(orientation !== 'vertical' && key === 'ArrowLeft')
if (key === 'Home') return 0
if (key === 'End') return count - 1
if (forward) return current < 0 ? 0 : (current + 1) % count
if (backward) return current < 0 ? count - 1 : (current - 1 + count) % count
return null
}
/**
* Items that belong to this container, skipping disabled ones and any that
* sit inside a nested instance of the same component.
*/
export function ownItems(
container: HTMLElement,
itemSelector: string,
containerSelector: string
): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(itemSelector)).filter(
el =>
el.closest(containerSelector) === container &&
!el.hasAttribute('disabled') &&
el.getAttribute('aria-disabled') !== 'true'
)
}
/**
* Typeahead: printable keys typed within half a second build a search
* string, and the first matching label after the current item wins.
*/
export function useTypeahead() {
const buffer = useRef('')
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
return (key: string, labels: string[], current: number): number | null => {
if (key.length !== 1 || key === ' ') return null
clearTimeout(timer.current)
timer.current = setTimeout(() => (buffer.current = ''), 500)
buffer.current += key.toLowerCase()
const search = buffer.current
const repeated = search.split('').every(char => char === search[0])
const query = repeated ? search[0] : search
const start = repeated ? current + 1 : current
for (let i = 0; i < labels.length; i++) {
const index = (start + i + labels.length) % labels.length
if (labels[index].trim().toLowerCase().startsWith(query)) return index
}
return null
}
}
/** Makes a value safe to use inside an id attribute. */
export const idSafe = (value: string) => value.replace(/[^\w-]/g, '_')
2. Import from that folder
The files import each other with relative paths, so keep them together, for example in components/ui.
Usage
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
AccordionTrigger
} from '@syncclouds/ui'<Accordion defaultValue={['item-1']}>
<AccordionItem value="item-1">
<AccordionHeader>
<AccordionTrigger>Is it accessible?</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>Yes. It follows the WAI-ARIA Accordion pattern.</AccordionPanel>
</AccordionItem>
</Accordion>Anatomy
Accordion
└── AccordionItem
├── AccordionHeader
│ └── AccordionTrigger
└── AccordionPanelMultiple
Set type="multiple" so each item opens and closes on its own.
View codeHide code
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
AccordionTrigger
} from '@syncclouds/ui'
const SETTINGS = [
{ value: 'notifications', title: 'Notifications', body: 'Choose which emails and push alerts you receive.' },
{ value: 'privacy', title: 'Privacy', body: 'Decide who can see your profile and activity.' },
{ value: 'billing', title: 'Billing', body: 'Update your payment method and download invoices.' }
]
export function AccordionMultiple() {
return (
<Accordion type="multiple" defaultValue={['notifications', 'privacy']} className="w-full max-w-md">
{SETTINGS.map(item => (
<AccordionItem key={item.value} value={item.value}>
<AccordionHeader>
<AccordionTrigger>{item.title}</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>{item.body}</AccordionPanel>
</AccordionItem>
))}
</Accordion>
)
}Disabled item
A disabled item keeps its place but its trigger does not respond.
View codeHide code
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
AccordionTrigger
} from '@syncclouds/ui'
export function AccordionDisabled() {
return (
<Accordion className="w-full max-w-md">
<AccordionItem value="history">
<AccordionHeader>
<AccordionTrigger>Account history</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>Every sign-in from the last 90 days, with device and location.</AccordionPanel>
</AccordionItem>
<AccordionItem value="exports" disabled>
<AccordionHeader>
<AccordionTrigger>Data exports (on paid plans)</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>Download everything in your workspace as CSV.</AccordionPanel>
</AccordionItem>
<AccordionItem value="email">
<AccordionHeader>
<AccordionTrigger>Email address</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>Change it here; we send a link to confirm the new address.</AccordionPanel>
</AccordionItem>
</Accordion>
)
}Heading level
AccordionHeader renders an h3. Use asChild to keep the page outline in order.
View codeHide code
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
AccordionTrigger
} from '@syncclouds/ui'
/* The page above this accordion already has an h2 and h3, so each header
renders an h4 to keep the outline in order. */
export function AccordionHeadingLevel() {
return (
<Accordion className="w-full max-w-md">
{['Monday', 'Tuesday'].map(day => (
<AccordionItem key={day} value={day}>
<AccordionHeader asChild>
<h4>
<AccordionTrigger>{day}</AccordionTrigger>
</h4>
</AccordionHeader>
<AccordionPanel>Open from 9:00 to 17:00.</AccordionPanel>
</AccordionItem>
))}
</Accordion>
)
}Without the theme
The same parts with only Tailwind CSS classes on their data attributes.
View codeHide code
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
AccordionTrigger
} from '@syncclouds/ui'
const ITEMS = [
{ value: 'one', title: 'What does headless mean?', body: 'The component brings behaviour and accessibility. You bring every style.' },
{ value: 'two', title: 'How do I style the open state?', body: 'Target data-state="open", as the classes on this example do.' }
]
export function AccordionUnstyled() {
return (
<Accordion defaultValue={['one']} className="w-full max-w-md divide-y divide-zinc-200 dark:divide-zinc-700">
{ITEMS.map(item => (
<AccordionItem key={item.value} value={item.value} className="py-1">
<AccordionHeader className="m-0">
<AccordionTrigger className="w-full rounded-md py-3 text-left font-medium text-zinc-900 outline-offset-2 focus-visible:outline-2 focus-visible:outline-indigo-600 data-[state=open]:text-indigo-700 dark:text-zinc-100 dark:data-[state=open]:text-indigo-300">
{item.title}
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel className="pb-3 text-zinc-600 dark:text-zinc-400">{item.body}</AccordionPanel>
</AccordionItem>
))}
</Accordion>
)
}Accessibility
Follows the WAI-ARIA Accordion pattern.
| Keys | What they do |
|---|---|
| Enter, Space | Opens or closes the focused item. |
| Tab | Moves to the next focusable element, including links inside open panels. |
| Arrow Down, Arrow Up | Moves to the next or previous header, wrapping at the ends. |
| Home, End | Moves to the first or last header. |
- Each panel is a region labelled by its trigger, so screen readers announce which section they are in.
- Closed panels use the hidden attribute, so their content is out of the tab order.
API reference
Every part also accepts the props of the element it renders, including className and ref.
Accordionrenders <div>
Holds the items and which of them are open.
| Prop | Type | Default | Description |
|---|---|---|---|
type | "single" | "multiple" | "single" | single: opening one item closes the others. multiple: items open on their own. |
value | string[] | none | The open items, when you control them. |
defaultValue | string[] | [] | The items open at first, when the accordion controls itself. |
onValueChange | (value: string[]) => void | none | Called with the open items after every change. |
| Data attribute | Values |
|---|---|
data-scope | "accordion" |
data-part | "root" |
AccordionItemrenders <div>
One heading and its panel.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | none | Required. Identifies the item in value and defaultValue. |
disabled | boolean | false | Stops the item from opening or closing. |
| Data attribute | Values |
|---|---|
data-state | "open" | "closed" |
data-disabled | present when disabled |
AccordionHeaderrenders <h3>
The heading around the trigger. Use asChild to render another heading level.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render your own heading element instead of an h3. |
| Data attribute | Values |
|---|---|
data-state | "open" | "closed" |
AccordionTriggerrenders <button>
Shows or hides the panel. Carries aria-expanded and aria-controls.
| Data attribute | Values |
|---|---|
data-state | "open" | "closed" |
data-disabled | present when disabled |
AccordionPanelrenders <div>
The content. A region named by its trigger, hidden while closed.
| Data attribute | Values |
|---|---|
data-state | "open" | "closed" |