Skip to content
Noksha UIv0.1
Colour theme

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.

No. Every token is a plain CSS custom property, so theming works with no React context at all. The provider exists only to toggle and persist the mode.

Several open

Compiled 22 components, 19.6 kB of CSS.

Promoted to production in eu-west-1.

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'>.

PropTypeDescription
variantAccordionVariant
disabledboolean

AccordionItemProps

Also accepts everything from React.HTMLAttributes<HTMLDivElement>.

PropTypeDescription
value*string
disabledboolean

AccordionTriggerProps

Also accepts everything from React.ButtonHTMLAttributes<HTMLButtonElement>.

PropTypeDescription
as'h2' | 'h3' | 'h4' | 'h5' | 'h6'Heading level for the wrapper, so the page outline stays correct.
indicatorReact.ReactNode | nullReplaces the chevron. Pass `null` for no indicator.

AccordionContentProps

Also accepts everything from React.HTMLAttributes<HTMLDivElement>.

PropTypeDescription
forceMountbooleanKeep 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.