navigation
Accordion
Collapsible sections that animate to their own content height with no JavaScript measurement.
Examples
One at a time
The panel animates to its own content height using a 0fr→1fr grid row — nothing is measured in JavaScript, so it stays correct when the content reflows.
Several open
Own the source
Noksha ships as a package and as copy-paste source. Take the files and they are yours to change. These are read from the generated registry, so they are exactly what the library ships — never a paraphrase of it.
Self-contained apart from @noksha-ui/core. Or let the CLI do it: npx @noksha-ui/cli add accordion writes them, follows the same dependency graph, and fixes up the imports.
import {
composeRefs,
ROVING_ITEM_ATTR,
useControllableState,
usePresence,
useRovingFocus,
} from '@noksha-ui/core';
import * as React from 'react';
import type {
AccordionContentProps,
AccordionItemProps,
AccordionProps,
AccordionTriggerProps,
AccordionVariant,
} from './accordion.types.js';
import {
accordionContentBodyVariants,
accordionContentInnerVariants,
accordionContentVariants,
accordionIndicatorVariants,
accordionItemVariants,
accordionTriggerVariants,
accordionVariants,
} from './accordion.variants.js';
interface AccordionContextValue {
isOpen: (value: string) => boolean;
toggle: (value: string) => void;
baseId: string;
variant: AccordionVariant;
disabled: boolean;
}
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
interface AccordionItemContextValue {
value: string;
open: boolean;
disabled: boolean;
}
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
function useAccordionContext(part: string): AccordionContextValue {
const context = React.useContext(AccordionContext);
if (!context) {
throw new Error(`[@noksha-ui/react] <Accordion.${part}> must be used inside <Accordion.Root>.`);
}
return context;
}
function useItemContext(part: string): AccordionItemContextValue {
const context = React.useContext(AccordionItemContext);
if (!context) {
throw new Error(`[@noksha-ui/react] <Accordion.${part}> must be used inside <Accordion.Item>.`);
}
return context;
}
const triggerId = (base: string, value: string) => `${base}-trigger-${value}`;
const panelId = (base: string, value: string) => `${base}-panel-${value}`;
/**
* Collapsible sections.
*
* ```tsx
* <Accordion.Root type="single" collapsible defaultValue="billing">
* <Accordion.Item value="billing">
* <Accordion.Trigger>Billing</Accordion.Trigger>
* <Accordion.Content>…</Accordion.Content>
* </Accordion.Item>
* </Accordion.Root>
* ```
*/
export const AccordionRoot = React.forwardRef<HTMLDivElement, AccordionProps>(
function AccordionRoot(props, forwardedRef) {
const {
type = 'single',
variant = 'bordered',
disabled = false,
className,
value: valueProp,
defaultValue,
onValueChange,
collapsible = false,
...rest
} = props as AccordionProps & {
type?: 'single' | 'multiple';
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: never) => void;
collapsible?: boolean;
};
const multiple = type === 'multiple';
const baseId = React.useId();
const listRef = React.useRef<HTMLDivElement>(null);
// Normalised to an array internally so the open/toggle logic is written once;
// the caller's shape is restored on the way back out.
const [value, setValue] = useControllableState<string[]>({
value:
valueProp === undefined
? undefined
: multiple
? (valueProp as string[])
: [valueProp as string],
defaultValue:
defaultValue === undefined
? []
: multiple
? (defaultValue as string[])
: [defaultValue as string],
onChange: (next) => {
if (multiple) (onValueChange as ((value: string[]) => void) | undefined)?.(next);
else (onValueChange as ((value: string) => void) | undefined)?.(next[0] ?? '');
},
});
const isOpen = React.useCallback((item: string) => value.includes(item), [value]);
const toggle = React.useCallback(
(item: string) => {
setValue((current) => {
const open = current.includes(item);
if (multiple)
return open ? current.filter((entry) => entry !== item) : [...current, item];
if (open) return collapsible ? [] : current;
return [item];
});
},
[setValue, multiple, collapsible],
);
// Arrow keys move between headers, per the WAI-ARIA accordion pattern.
const { onKeyDown } = useRovingFocus({ ref: listRef, orientation: 'vertical' });
const context = React.useMemo<AccordionContextValue>(
() => ({ isOpen, toggle, baseId, variant, disabled }),
[isOpen, toggle, baseId, variant, disabled],
);
return (
<AccordionContext.Provider value={context}>
{/* biome-ignore lint/a11y/noStaticElementInteractions: a keydown delegate for the real controls inside; the container is not itself operable */}
<div
ref={composeRefs(forwardedRef, listRef)}
className={accordionVariants({ variant, className })}
{...rest}
onKeyDown={(event) => {
rest.onKeyDown?.(event);
onKeyDown(event);
}}
/>
</AccordionContext.Provider>
);
},
);
AccordionRoot.displayName = 'Accordion.Root';
export const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
function AccordionItem({ value, disabled = false, className, ...rest }, ref) {
const { isOpen, variant, disabled: rootDisabled } = useAccordionContext('Item');
const open = isOpen(value);
const itemDisabled = disabled || rootDisabled;
const context = React.useMemo<AccordionItemContextValue>(
() => ({ value, open, disabled: itemDisabled }),
[value, open, itemDisabled],
);
return (
<AccordionItemContext.Provider value={context}>
<div
ref={ref}
data-state={open ? 'open' : 'closed'}
data-disabled={itemDisabled || undefined}
className={accordionItemVariants({ variant, className })}
{...rest}
/>
</AccordionItemContext.Provider>
);
},
);
AccordionItem.displayName = 'Accordion.Item';
const ChevronIcon = () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
export const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
function AccordionTrigger({ as: Heading = 'h3', indicator, className, children, ...rest }, ref) {
const { toggle, baseId } = useAccordionContext('Trigger');
const { value, open, disabled } = useItemContext('Trigger');
const resolvedIndicator = indicator === undefined ? <ChevronIcon /> : indicator;
return (
// The button is wrapped in a heading so the sections show up in a screen
// reader's headings list — which is how people navigate a long FAQ.
<Heading className="m-0">
<button
ref={ref}
type="button"
id={triggerId(baseId, value)}
aria-expanded={open}
aria-controls={panelId(baseId, value)}
disabled={disabled}
data-state={open ? 'open' : 'closed'}
data-value={value}
{...{ [ROVING_ITEM_ATTR]: '' }}
className={`group/trigger ${accordionTriggerVariants({ className })}`}
{...rest}
onClick={(event) => {
rest.onClick?.(event);
if (!event.defaultPrevented) toggle(value);
}}
>
{children}
{resolvedIndicator ? (
<span className={accordionIndicatorVariants()}>{resolvedIndicator}</span>
) : null}
</button>
</Heading>
);
},
);
AccordionTrigger.displayName = 'Accordion.Trigger';
export const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
function AccordionContent({ forceMount = false, className, children, ...rest }, ref) {
const { baseId } = useAccordionContext('Content');
const { value, open } = useItemContext('Content');
const panelRef = React.useRef<HTMLElement>(null);
const present = usePresence(open, panelRef);
/**
* Collapsed panels are unmounted rather than merely hidden.
*
* A zero-height panel that is still in the DOM is still in the accessibility
* tree, so a screen reader reads out every collapsed section of a long FAQ.
* `usePresence` keeps it mounted only for as long as the closing animation
* needs, which is what lets it be both animated and genuinely gone.
*/
if (!present && !forceMount) return null;
return (
<section
ref={panelRef}
id={panelId(baseId, value)}
aria-labelledby={triggerId(baseId, value)}
hidden={!present && forceMount}
data-state={open ? 'open' : 'closed'}
className={accordionContentVariants()}
>
{/* Owns `overflow: hidden`, which is what makes the 0fr row clip. */}
<div className={accordionContentInnerVariants()}>
<div ref={ref} className={accordionContentBodyVariants({ className })} {...rest}>
{children}
</div>
</div>
</section>
);
},
);
AccordionContent.displayName = 'Accordion.Content';
export const Accordion = {
Root: AccordionRoot,
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
};
export { accordionVariants };API reference
Accepted values
- AccordionVariant
borderedseparatedghost
AccordionBaseProps
Also accepts everything from Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'defaultValue'>.
| Prop | Type | Description |
|---|---|---|
| variant | AccordionVariant | — |
| disabled | boolean | — |
AccordionItemProps
Also accepts everything from React.HTMLAttributes<HTMLDivElement>.
| Prop | Type | Description |
|---|---|---|
| value* | string | — |
| disabled | boolean | — |
AccordionTriggerProps
Also accepts everything from React.ButtonHTMLAttributes<HTMLButtonElement>.
| Prop | Type | Description |
|---|---|---|
| as | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | Heading level for the wrapper, so the page outline stays correct. |
| indicator | React.ReactNode | null | Replaces the chevron. Pass `null` for no indicator. |
AccordionContentProps
Also accepts everything from React.HTMLAttributes<HTMLDivElement>.
| Prop | Type | Description |
|---|---|---|
| forceMount | boolean | Keep the panel mounted while collapsed — for content that must not lose its state, such as a partly-filled form. Off by default: a collapsed panel that stays in the DOM stays in the accessibility tree too, and a screen reader will read every section of a long page whether it is open or not. |