Skip to content

AccordionNested

MD · 1950B gzip budget

Accordion supporting multiple levels of nested sections.

Category
Media & layout
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale
Content

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/accordion-nested.json

Copies the source into your project.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import React, { createContext, useCallback, useContext, useState } from "react";

interface AccordionContextValue {
  expandedItems: Set<string>;
  toggle: (id: string) => void;
}

const AccordionContext = createContext<AccordionContextValue>({
  expandedItems: new Set(),
  toggle: () => {},
});

export interface AccordionNestedProps {
  children: React.ReactNode;
  className?: string;
  ariaLabel?: string;
  allowMultiple?: boolean;
}

export function AccordionNested({
  children,
  className = "",
  ariaLabel,
  allowMultiple = false,
}: AccordionNestedProps) {
  const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());

  const toggle = useCallback(
    (id: string) => {
      setExpandedItems((prev) => {
        const next = new Set(allowMultiple ? prev : []);
        if (prev.has(id)) {
          next.delete(id);
        } else {
          next.add(id);
        }
        return next;
      });
    },
    [allowMultiple]
  );

  return (
    <AccordionContext.Provider value={{ expandedItems, toggle }}>
      <div
        className={className}
        role="region"
        aria-label={ariaLabel}
      >
        {children}
      </div>
    </AccordionContext.Provider>
  );
}

export interface AccordionItemProps {
  id: string;
  trigger: React.ReactNode;
  children: React.ReactNode;
  className?: string;
}

export function AccordionItem({
  id,
  trigger,
  children,
  className = "",
}: AccordionItemProps) {
  const { expandedItems, toggle } = useContext(AccordionContext);
  const isExpanded = expandedItems.has(id);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        toggle(id);
      }
    },
    [id, toggle]
  );

  return (
    <div className={className} data-state={isExpanded ? "open" : "closed"}>
      <h3>
        <button
          type="button"
          aria-expanded={isExpanded}
          aria-controls={`${id}-content`}
          id={`${id}-trigger`}
          onClick={() => toggle(id)}
          onKeyDown={handleKeyDown}
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            width: "100%",
            padding: "1rem",
            background: "none",
            border: "none",
            cursor: "pointer",
            fontWeight: 500,
            textAlign: "start",
          }}
        >
          {trigger}
          <span
            aria-hidden="true"
            style={{
              display: "inline-block",
              transition: "transform 0.2s ease",
              transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
              marginInlineStart: "0.5rem",
            }}
          >
            ▾
          </span>
        </button>
      </h3>
      <div
        id={`${id}-content`}
        role="region"
        aria-labelledby={`${id}-trigger`}
        hidden={!isExpanded}
        style={{
          overflow: "hidden",
          transition: "max-height 0.3s ease",
          maxHeight: isExpanded ? "500px" : 0,
        }}
      >
        <div style={{ padding: "0 1rem 1rem" }}>
          {children}
        </div>
      </div>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation