GitHub

Accordion

A vertically stacked set of collapsible sections. Supports single and multiple expansion, controlled mode, and smooth CSS animation.

AccessibleDark ModeAnimatedSingle / MultipleControlledCompound
01

Theme Preview

First item open, remaining items collapsed — forced light and dark styling.

Light
Is it accessible?
Yes. Uses aria-expanded and keyboard-navigable buttons.
Is it animated?
Controlled mode?
Dark
Is it accessible?
Yes. Uses aria-expanded and keyboard-navigable buttons.
Is it animated?
Controlled mode?
02

Live Demo

Type

Only one item open at a time.

Yes. Each trigger is a native <button> with aria-expanded. Keyboard navigation works out of the box — Tab moves focus and Enter/Space toggles.
Yes. Content panels expand and collapse using a CSS grid-template-rows transition from 0fr to 1fr, giving a smooth height animation without JavaScript.
Yes. Pass value and onChange to control open items externally. Omit them and use defaultValue for fully uncontrolled behaviour.
Yes. Set type="multiple". You can also set defaultValue to an array of item values to open multiple items initially.
Set collapsible={false} on the Accordion. This only applies to type="single" — the open item won't collapse when clicked again.

collapsible={false} — active item cannot be collapsed.

This component requires React 19+ and uses createContext, useState, and useContext from the standard React package.
Styling is fully Tailwind-based with Tailwind v4. Theme tokens and variants are configured in src/app/globals.css.
Uses cn() from @/shared/utils/cn (clsx + tailwind-merge) for conditional class merging.
03

Code Snippet

src/ui/Accordion.tsx
import { Accordion } from "@/ui/Accordion";

// Single — only one item open at a time
<Accordion.Root type="single" defaultValue="item-1">
  <Accordion.Item value="item-1">
    <Accordion.Trigger>Is it accessible?</Accordion.Trigger>
    <Accordion.Content>
      Yes. It uses aria-expanded and keyboard-navigable buttons.
    </Accordion.Content>
  </Accordion.Item>
  <Accordion.Item value="item-2">
    <Accordion.Trigger>Is it animated?</Accordion.Trigger>
    <Accordion.Content>
      Yes. Content expands with a CSS grid-template-rows transition.
    </Accordion.Content>
  </Accordion.Item>
</Accordion.Root>

// Multiple — any number of items open simultaneously
<Accordion.Root type="multiple" defaultValue={["item-1", "item-3"]}>
  ...
</Accordion.Root>

// Controlled
<Accordion.Root type="single" value={open} onChange={(v) => setOpen(v as string)}>
  ...
</Accordion.Root>

// Prevent collapse (collapsible=false)
<Accordion.Root type="single" collapsible={false} defaultValue="item-1">
  ...
</Accordion.Root>

Flat exports seperti AccordionItem, AccordionTrigger, danAccordionContent tetap didukung untuk migrasi bertahap.

04

Copy-Paste (Single File)

Snippet ini self-contained dengan context + animation logic di satu file agar minim setup.

Accordion.tsx
"use client";

import {
  createContext,
  useContext,
  useState,
  type ButtonHTMLAttributes,
  type HTMLAttributes,
  type ReactNode,
} from "react";

type ClassValue = string | false | null | undefined;
const cn = (...classes: ClassValue[]) => classes.filter(Boolean).join(" ");

type AccordionType = "single" | "multiple";

interface AccordionProps {
  type?: AccordionType;
  defaultValue?: string | string[];
  value?: string | string[];
  onChange?: (value: string | string[]) => void;
  collapsible?: boolean;
  children: ReactNode;
  className?: string;
}

interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> {
  value: string;
  children: ReactNode;
}

interface AccordionContextValue {
  isOpen: (value: string) => boolean;
  toggle: (value: string) => void;
}

interface ItemContextValue {
  value: string;
  open: boolean;
}

const AccordionContext = createContext<AccordionContextValue | null>(null);
const ItemContext = createContext<ItemContextValue | null>(null);

function useAccordionContext() {
  const context = useContext(AccordionContext);
  if (!context) throw new Error("Accordion sub-components must be used inside <Accordion.Root>");
  return context;
}

function useAccordionItem() {
  const context = useContext(ItemContext);
  if (!context) throw new Error("Accordion sub-components must be used inside <Accordion.Item>");
  return context;
}

function AccordionRoot({
  type = "single",
  defaultValue,
  value: controlledValue,
  onChange,
  collapsible = true,
  children,
  className,
}: AccordionProps) {
  const toSet = (next?: string | string[]) => {
    if (!next) return new Set<string>();
    return new Set(Array.isArray(next) ? next : [next]);
  };

  const [internal, setInternal] = useState<Set<string>>(() => toSet(defaultValue));
  const isControlled = controlledValue !== undefined;
  const active = isControlled ? toSet(controlledValue) : internal;

  const toggle = (nextValue: string) => {
    let next: Set<string>;

    if (type === "single") {
      if (active.has(nextValue)) {
        next = collapsible ? new Set() : new Set([nextValue]);
      } else {
        next = new Set([nextValue]);
      }
    } else {
      next = new Set(active);
      if (next.has(nextValue)) next.delete(nextValue);
      else next.add(nextValue);
    }

    if (!isControlled) setInternal(next);
    if (onChange) {
      const values = [...next];
      onChange(type === "single" ? (values[0] ?? "") : values);
    }
  };

  return (
    <AccordionContext.Provider value={{ isOpen: (v) => active.has(v), toggle }}>
      <div className={cn("w-full overflow-hidden rounded-xl border border-slate-200", className)}>{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionItem({ value, className, children, ...props }: AccordionItemProps) {
  const { isOpen } = useAccordionContext();
  const open = isOpen(value);

  return (
    <ItemContext.Provider value={{ value, open }}>
      <div className={cn("border-b border-slate-200 bg-white last:border-b-0", className)} {...props}>
        {children}
      </div>
    </ItemContext.Provider>
  );
}

function AccordionTrigger({ children, className, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
  const { toggle } = useAccordionContext();
  const { value, open } = useAccordionItem();

  return (
    <button
      type="button"
      aria-expanded={open}
      onClick={() => toggle(value)}
      className={cn("flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium", className)}
      {...props}
    >
      <span>{children}</span>
      <span className={cn("transition-transform", open && "rotate-180")}></span>
    </button>
  );
}

function AccordionContent({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) {
  const { open } = useAccordionItem();

  return (
    <div style={{ display: "grid", gridTemplateRows: open ? "1fr" : "0fr", transition: "grid-template-rows .2s ease" }}>
      <div style={{ overflow: "hidden" }}>
        <div className={cn("px-5 pb-4 text-sm text-slate-600", className)} {...props}>
          {children}
        </div>
      </div>
    </div>
  );
}

type AccordionCompound = typeof AccordionRoot & {
  Root: typeof AccordionRoot;
  Item: typeof AccordionItem;
  Trigger: typeof AccordionTrigger;
  Content: typeof AccordionContent;
};

export const Accordion = Object.assign(AccordionRoot, {
  Root: AccordionRoot,
  Item: AccordionItem,
  Trigger: AccordionTrigger,
  Content: AccordionContent,
}) as AccordionCompound;
05

Props

ComponentPropTypeDefaultDescription
Accordion.Roottype"single" | "multiple""single"Whether one or multiple items can be open at a time.
Accordion.RootdefaultValuestring | string[]Initially open item(s) — uncontrolled.
Accordion.Rootvaluestring | string[]Controlled open item(s).
Accordion.RootonChange(value: string | string[]) => voidCallback when open items change.
Accordion.RootcollapsiblebooleantrueWhen type=single, allow closing the open item by clicking it.
Accordion.ItemvaluestringUnique identifier for this item.
Accordion.TriggerButtonHTMLAttributesExtends all native button attributes.
Accordion.ContentHTMLAttributes<div>Animated content panel.
react-principles