Skip to content
UI Kit
Accent color

Accordion for React

Disclosure rows on native details: readable without JavaScript, one or many open, smooth where the browser allows.

One of 6 components in Navigation elements

3 variants

Disclosure rows that work everywhere

An accordion folds long content into short rows that open on demand. This one is built on the native details and summary elements, so the content is in the HTML for search engines, it opens with Enter and Space, and it works even when JavaScript fails to load.

One at a time closes the open row when another opens, good for FAQs where people read one answer. Cards gives every row its own surface, for pricing details or product specs. With icons adds an icon per row for settings and help topics. All three can also allow many rows open at once, and the browser search finds text inside closed rows.

Write each summary as a full question or a clear topic, people decide by the label alone. Keep the open content short, an accordion inside an accordion is a sign the page needs another structure. Open the most important row by default when it helps.

Variants

One at a time

Hairlines between rows, opening one closes the other. The plus turns into a minus.

Cards

Separate surfaces, several open at once. The open card rises a little.

With icons

One bordered box with icons and a line under each title, for shop and service details.

Details

What Accordion brings along, read from the registry and the source.

Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/accordion.tsx
Brings along
utilskiticon
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The AccordionProps interface from components/ui/accordion.tsx, with the comments from the code.

  • itemsrequired
    AccordionItem[]
  • typeoptional
    'single' | 'multiple'

    single: opening one closes the others (native <details name>). multiple: each on its own.

  • defaultOpenoptional
    string[]

    Values open on first render

  • variantoptional
    'divided' | 'cards' | 'contained'

    divided: hairlines between rows. cards: separate surfaces. contained: one bordered box.

  • markeroptional
    'chevron' | 'plus'

    chevron or plus, the plus turns into a minus

  • headingLeveloptional
    2 | 3 | 4

    Heading level of the titles, for a correct outline

Accessibility

Read from the source of the component.

  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 5 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 191 lines
components/ui/accordion.tsx
'use client';

import { useEffect, useId, useRef } from 'react';
import { cn, prefersReducedMotion, useInView } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

export interface AccordionItem {
  /** Stable id, used for the anchor and defaultOpen */
  value: string;
  title: React.ReactNode;
  content: React.ReactNode;
  icon?: IconName;
  /** Quiet text under the title */
  subtitle?: React.ReactNode;
}

export interface AccordionProps {
  items: AccordionItem[];
  /** single: opening one closes the others (native <details name>). multiple: each on its own. */
  type?: 'single' | 'multiple';
  /** Values open on first render */
  defaultOpen?: string[];
  /** divided: hairlines between rows. cards: separate surfaces. contained: one bordered box. */
  variant?: 'divided' | 'cards' | 'contained';
  /** chevron or plus, the plus turns into a minus */
  marker?: 'chevron' | 'plus';
  /** Heading level of the titles, for a correct outline */
  headingLevel?: 2 | 3 | 4;
  className?: string;
}

/*
 * Open and closed follow [open], except while a row closes: then data-closing turns
 * the marker back at once, while the height still runs. Rows rise one after another
 * when the list scrolls into view.
 */
const STYLES =
  '.kitfx-acc-chev,.kitfx-acc-plus{transition:transform .35s var(--ease-kit),translate .2s var(--ease-kit)}' +
  '.kitfx-acc[open]:not([data-closing]) .kitfx-acc-chev{transform:rotate(180deg)}' +
  '.kitfx-acc[open]:not([data-closing]) .kitfx-acc-plus{transform:scaleY(0)}' +
  '.kitfx-acc-card{transition:border-color .3s,box-shadow .3s,translate .3s var(--ease-kit)}' +
  '.kitfx-acc-card[open]:not([data-closing]){border-color:var(--border-strong);box-shadow:var(--highlight),var(--shadow-md)}' +
  '.kitfx-acc-in>.kitfx-acc{animation:kitfx-acc-in .6s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 70ms)}' +
  '@keyframes kitfx-acc-in{from{opacity:0;transform:translateY(12px)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-acc-chev,.kitfx-acc-plus,.kitfx-acc-card{transition:none}.kitfx-acc-in>.kitfx-acc{animation:none}}';

const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';

/**
 * Disclosure rows on native <details>: work without JavaScript, open with Enter and Space, searchable with Ctrl F
 * in browsers that support it. With JavaScript the height opens and closes softly (grid rows 0fr to 1fr),
 * the text fades in and the marker turns.
 *   <Accordion type="single" items={[{ value: 'refund', title: 'Can I get a refund?', content: '…' }]} />
 */
export function Accordion({ items, type = 'single', defaultOpen = [], variant = 'divided', marker = 'chevron', headingLevel = 3, className }: AccordionProps) {
  const group = useId();
  const H = `h${headingLevel}` as 'h3';
  const [ref, inView] = useInView<HTMLDivElement>();
  const hidden = useRef(false);
  if (!inView) hidden.current = true;
  const entering = inView && hidden.current;
  const timers = useRef(new Map<HTMLDetailsElement, number>());

  // The script takes over "one at a time", so the others can close softly instead of snapping shut
  useEffect(() => {
    if (prefersReducedMotion()) return;
    ref.current?.querySelectorAll('details[name]').forEach((d) => d.removeAttribute('name'));
  }, [ref]);
  useEffect(() => {
    const map = timers.current;
    return () => map.forEach((t) => window.clearTimeout(t));
  }, []);

  const body = (d: HTMLDetailsElement) => d.querySelector<HTMLElement>(':scope > [data-acc-body]');

  const shut = (d: HTMLDetailsElement) => {
    const b = body(d);
    if (!b || !d.open || d.dataset.closing !== undefined) return;
    window.clearTimeout(timers.current.get(d));
    d.dataset.closing = '';
    (b.firstElementChild as HTMLElement | null)?.style.setProperty('overflow', 'hidden');
    b.dataset.shut = '';
    timers.current.set(
      d,
      window.setTimeout(() => {
        d.open = false;
        delete d.dataset.closing;
        delete b.dataset.shut;
        (b.firstElementChild as HTMLElement | null)?.style.removeProperty('overflow');
      }, 380),
    );
  };

  const show = (d: HTMLDetailsElement) => {
    const b = body(d);
    if (!b) return;
    window.clearTimeout(timers.current.get(d));
    const inner = b.firstElementChild as HTMLElement | null;
    inner?.style.setProperty('overflow', 'hidden');
    if (!d.open) {
      b.dataset.shut = '';
      d.open = true;
      void b.offsetHeight;
    }
    delete d.dataset.closing;
    delete b.dataset.shut;
    inner?.firstElementChild?.animate([{ opacity: 0, transform: 'translateY(-6px)' }, { opacity: 1, transform: 'none' }], { duration: 380, delay: 60, easing: EASE, fill: 'backwards' });
    timers.current.set(
      d,
      window.setTimeout(() => inner?.style.removeProperty('overflow'), 420),
    );
  };

  const onToggle = (e: React.MouseEvent<HTMLElement>) => {
    if (prefersReducedMotion()) return;
    const d = e.currentTarget.parentElement as HTMLDetailsElement;
    e.preventDefault();
    if (d.open && d.dataset.closing === undefined) {
      shut(d);
      return;
    }
    if (type === 'single') ref.current?.querySelectorAll<HTMLDetailsElement>(':scope > details[open]').forEach((o) => o !== d && shut(o));
    show(d);
  };

  return (
    <div
      ref={ref}
      className={cn(
        'w-full',
        variant === 'divided' && 'divide-y divide-border border-y border-border',
        variant === 'cards' && 'grid gap-3',
        variant === 'contained' && 'divide-y divide-border overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]',
        !inView && '[&>details]:opacity-0',
        entering && 'kitfx-acc-in',
        className,
      )}
    >
      <style href="kitfx-acc" precedence="default">
        {STYLES}
      </style>
      {items.map((it, i) => (
        <details
          key={it.value}
          id={it.value}
          name={type === 'single' ? group : undefined}
          open={defaultOpen.includes(it.value) || undefined}
          style={{ '--i': i } as React.CSSProperties}
          className={cn('kitfx-acc group/acc', variant === 'cards' && 'kitfx-acc-card rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] hover:-translate-y-px motion-reduce:hover:translate-y-0')}
        >
          <summary
            onClick={onToggle}
            className={cn(
              'flex min-h-14 cursor-pointer list-none items-center gap-4 py-4 text-left outline-none [&::-webkit-details-marker]:hidden',
              'rounded-lg focus-visible:ring-2 focus-visible:ring-ring',
              variant !== 'divided' && 'px-5',
              variant === 'divided' && 'hover:text-foreground',
            )}
          >
            {it.icon && (
              <span className="grid size-9 shrink-0 place-items-center rounded-lg border border-border bg-surface text-muted-foreground transition-[color,scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-open/acc:text-foreground group-hover/acc:scale-105 motion-reduce:transition-none">
                <Icon name={it.icon} size={17} />
              </span>
            )}
            <span className="min-w-0 flex-1">
              <H className="text-pretty text-[15px] font-medium leading-snug text-foreground">{it.title}</H>
              {it.subtitle && <span className="mt-0.5 block text-[13px] text-muted-foreground">{it.subtitle}</span>}
            </span>
            {marker === 'chevron' ? (
              <span className="grid shrink-0 place-items-center text-muted-foreground transition-[translate] duration-200 group-hover/acc:translate-y-0.5 motion-reduce:transition-none">
                <Icon name="chevron-down" size={18} className="kitfx-acc-chev" />
              </span>
            ) : (
              <span aria-hidden className="relative grid size-7 shrink-0 place-items-center rounded-full border border-border text-muted-foreground transition-[color,border-color,rotate] duration-300 ease-[var(--ease-kit)] group-open/acc:border-border-strong group-open/acc:text-foreground group-hover/acc:rotate-90 group-open/acc:group-hover/acc:rotate-0 motion-reduce:transition-none">
                <span className="absolute h-[1.5px] w-3 rounded-full bg-current" />
                <span className="kitfx-acc-plus absolute h-3 w-[1.5px] rounded-full bg-current" />
              </span>
            )}
          </summary>
          <div data-acc-body className="grid grid-rows-[1fr] transition-[grid-template-rows] duration-[380ms] ease-[var(--ease-kit)] data-[shut]:grid-rows-[0fr] motion-reduce:transition-none">
            <div className="min-h-0">
              <div className={cn('pb-5 text-pretty text-sm leading-relaxed text-muted-foreground', variant !== 'divided' && 'px-5', it.icon && (variant === 'divided' ? 'ps-[52px]' : 'ps-[72px]'), marker === 'plus' ? 'pe-12' : 'pe-10')}>
                {it.content}
              </div>
            </div>
          </div>
        </details>
      ))}
    </div>
  );
}

Frequent questions

How do I install Accordion?

With aiw add @ai-website/accordion. The command puts components/ui/accordion.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Accordion have?

3 variants, One at a time, Cards and With icons. You pick one with the variant prop, for example variant="contained".

Can an AI assistant build with Accordion?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Accordion with aiw_components and install it with aiw_add @ai-website/accordion. Name the variant in your prompt, for example variant="cards", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard