Web InnoventixFreeCode

Nested Accordion

Original · free

An accordion with nested sub-accordions.

byWeb InnoventixReact + Tailwind
accordionnestedaccordions
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/accordion-nested.json
accordion-nested.tsx
"use client";

import { useId, useState } from "react";

type SubItem = {
  id: string;
  q: string;
  a: string;
};

type Section = {
  id: string;
  label: string;
  hint: string;
  items: SubItem[];
};

const SECTIONS: Section[] = [
  {
    id: "getting-started",
    label: "Getting started",
    hint: "Set up your workspace in minutes",
    items: [
      {
        id: "gs-invite",
        q: "How do I invite my team?",
        a: "Open Settings → Members, paste up to 50 email addresses at once, and pick a role. Invitees get a magic link that stays valid for 7 days. You can resend or revoke any pending invite from the same screen.",
      },
      {
        id: "gs-import",
        q: "Can I import data from another tool?",
        a: "Yes. We support one-click CSV import plus native migrations from Notion, Linear, and Trello. Boards, labels, and comment history come across; attachments over 25 MB are skipped and listed in the import report.",
      },
      {
        id: "gs-roles",
        q: "What can each role do?",
        a: "Owners manage billing and can delete the workspace. Admins manage members and integrations. Members create and edit content. Guests see only the projects they are added to and can never change workspace settings.",
      },
    ],
  },
  {
    id: "billing",
    label: "Billing & plans",
    hint: "Invoices, seats, and upgrades",
    items: [
      {
        id: "bl-proration",
        q: "How does mid-cycle upgrading work?",
        a: "When you add seats or move to a higher tier, we charge a prorated amount for the days left in the current cycle and bill the full price from the next renewal. Downgrades take effect at the end of the period so you keep what you paid for.",
      },
      {
        id: "bl-invoice",
        q: "Where do I find past invoices?",
        a: "Every invoice lives under Settings → Billing → History as a PDF. Add a billing email and VAT ID there and both appear on future documents automatically. Invoices are also emailed to the workspace owner on each charge.",
      },
      {
        id: "bl-refund",
        q: "What is your refund policy?",
        a: "Annual plans are refundable in full within 14 days of the initial charge, no questions asked. Monthly plans are not prorated on cancellation, but you keep access until the paid period ends.",
      },
    ],
  },
  {
    id: "security",
    label: "Security & privacy",
    hint: "How we protect your data",
    items: [
      {
        id: "sc-encryption",
        q: "Is my data encrypted?",
        a: "All traffic uses TLS 1.3 in transit and data at rest is encrypted with AES-256. Encryption keys are rotated automatically and managed through a hardware security module, never stored alongside the data they protect.",
      },
      {
        id: "sc-sso",
        q: "Do you support SSO and SCIM?",
        a: "SAML single sign-on with Okta, Google Workspace, and Microsoft Entra is available on the Business plan. SCIM provisioning keeps membership in sync so removing someone in your identity provider revokes their access within minutes.",
      },
      {
        id: "sc-region",
        q: "Where is my data hosted?",
        a: "You choose a data region (EU or US) when the workspace is created. Backups stay inside that region and are retained for 30 days. We never move production data across regions without explicit written consent.",
      },
    ],
  },
];

export default function AccordionNested() {
  const uid = useId();
  const prefix = `accn-${uid.replace(/[:]/g, "")}`;
  const [openSection, setOpenSection] = useState<string | null>(SECTIONS[0].id);
  const [openItems, setOpenItems] = useState<Set<string>>(new Set());

  const toggleSection = (id: string) =>
    setOpenSection((cur) => (cur === id ? null : id));

  const toggleItem = (id: string) =>
    setOpenItems((cur) => {
      const next = new Set(cur);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 dark:bg-slate-950">
      <style>{`
        @keyframes ${prefix}-reveal {
          from { opacity: 0; transform: translateY(-4px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .${prefix}-panel {
          display: grid;
          grid-template-rows: 0fr;
          transition: grid-template-rows 320ms cubic-bezier(0.4, 0, 0.2, 1);
        }
        .${prefix}-panel[data-open="true"] { grid-template-rows: 1fr; }
        .${prefix}-panel > .${prefix}-clip {
          overflow: hidden;
          min-height: 0;
          visibility: hidden;
          transition: visibility 0s linear 320ms;
        }
        .${prefix}-panel[data-open="true"] > .${prefix}-clip {
          visibility: visible;
          transition-delay: 0s;
        }
        .${prefix}-chev { transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1); }
        .${prefix}-chev[data-open="true"] { transform: rotate(180deg); }
        .${prefix}-inner[data-open="true"] { animation: ${prefix}-reveal 340ms ease both; }
        @media (prefers-reduced-motion: reduce) {
          .${prefix}-panel { transition: none; }
          .${prefix}-panel > .${prefix}-clip,
          .${prefix}-panel[data-open="true"] > .${prefix}-clip { transition: none; }
          .${prefix}-chev { transition: none; }
          .${prefix}-inner[data-open="true"] { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="mb-10 text-center">
          <span className="inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            Help center
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white">
            Frequently asked questions
          </h2>
          <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
            Browse a category, then open the questions that matter to you.
          </p>
        </div>

        <div className="space-y-3">
          {SECTIONS.map((section) => {
            const sectionOpen = openSection === section.id;
            const sectionBtnId = `${prefix}-sec-btn-${section.id}`;
            const sectionPanelId = `${prefix}-sec-panel-${section.id}`;
            return (
              <div
                key={section.id}
                className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
              >
                <h3 className="m-0">
                  <button
                    type="button"
                    id={sectionBtnId}
                    aria-expanded={sectionOpen}
                    aria-controls={sectionPanelId}
                    onClick={() => toggleSection(section.id)}
                    className="flex w-full items-center gap-4 px-5 py-4 text-left transition-colors hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:hover:bg-slate-800/60"
                  >
                    <span
                      aria-hidden="true"
                      className="flex h-9 w-9 flex-none items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300"
                    >
                      <SectionIcon id={section.id} />
                    </span>
                    <span className="flex-1">
                      <span className="block text-base font-semibold text-slate-900 dark:text-white">
                        {section.label}
                      </span>
                      <span className="block text-xs text-slate-500 dark:text-slate-400">
                        {section.hint}
                      </span>
                    </span>
                    <svg
                      className={`${prefix}-chev h-5 w-5 flex-none text-slate-400 dark:text-slate-500`}
                      data-open={sectionOpen}
                      viewBox="0 0 20 20"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.75"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M5 7.5 10 12.5 15 7.5" />
                    </svg>
                  </button>
                </h3>

                <div
                  className={`${prefix}-panel`}
                  data-open={sectionOpen}
                  id={sectionPanelId}
                  role="region"
                  aria-labelledby={sectionBtnId}
                >
                  <div className={`${prefix}-clip`}>
                    <div
                      className={`${prefix}-inner space-y-1.5 border-t border-slate-100 px-3 pb-3 pt-3 dark:border-slate-800`}
                      data-open={sectionOpen}
                    >
                      {section.items.map((item) => {
                        const itemOpen = openItems.has(item.id);
                        const itemBtnId = `${prefix}-item-btn-${item.id}`;
                        const itemPanelId = `${prefix}-item-panel-${item.id}`;
                        return (
                          <div
                            key={item.id}
                            className="overflow-hidden rounded-xl border border-slate-200/70 bg-slate-50/60 dark:border-slate-800 dark:bg-slate-950/40"
                          >
                            <h4 className="m-0">
                              <button
                                type="button"
                                id={itemBtnId}
                                aria-expanded={itemOpen}
                                aria-controls={itemPanelId}
                                onClick={() => toggleItem(item.id)}
                                className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:hover:bg-slate-800/60"
                              >
                                <span
                                  aria-hidden="true"
                                  className="mt-0.5 flex h-1.5 w-1.5 flex-none rounded-full bg-indigo-400 dark:bg-indigo-500"
                                />
                                <span className="flex-1 text-sm font-medium text-slate-800 dark:text-slate-100">
                                  {item.q}
                                </span>
                                <svg
                                  className={`${prefix}-chev h-4 w-4 flex-none text-slate-400 dark:text-slate-500`}
                                  data-open={itemOpen}
                                  viewBox="0 0 20 20"
                                  fill="none"
                                  stroke="currentColor"
                                  strokeWidth="1.75"
                                  strokeLinecap="round"
                                  strokeLinejoin="round"
                                  aria-hidden="true"
                                >
                                  <path d="M5 7.5 10 12.5 15 7.5" />
                                </svg>
                              </button>
                            </h4>
                            <div
                              className={`${prefix}-panel`}
                              data-open={itemOpen}
                              id={itemPanelId}
                              role="region"
                              aria-labelledby={itemBtnId}
                            >
                              <div className={`${prefix}-clip`}>
                                <p className="m-0 px-4 pb-4 pl-7 pt-0.5 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                                  {item.a}
                                </p>
                              </div>
                            </div>
                          </div>
                        );
                      })}
                    </div>
                  </div>
                </div>
              </div>
            );
          })}
        </div>

        <p className="mt-8 text-center text-sm text-slate-500 dark:text-slate-400">
          Still stuck?{" "}
          <a
            href="#contact"
            className="font-medium text-indigo-600 underline-offset-4 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-400"
          >
            Contact support
          </a>
          .
        </p>
      </div>
    </section>
  );
}

function SectionIcon({ id }: { id: string }) {
  const common = {
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.75,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    className: "h-5 w-5",
    "aria-hidden": true,
  };
  if (id === "billing") {
    return (
      <svg {...common}>
        <rect x="2.5" y="5.5" width="19" height="13" rx="2.5" />
        <path d="M2.5 9.5h19" />
        <path d="M6.5 14.5h4" />
      </svg>
    );
  }
  if (id === "security") {
    return (
      <svg {...common}>
        <path d="M12 3 5 6v5c0 4.2 2.9 7.6 7 9 4.1-1.4 7-4.8 7-9V6l-7-3Z" />
        <path d="m9.5 12 1.8 1.8 3.2-3.6" />
      </svg>
    );
  }
  return (
    <svg {...common}>
      <path d="M12 3.5 3.5 8 12 12.5 20.5 8 12 3.5Z" />
      <path d="M3.5 12 12 16.5 20.5 12" />
      <path d="M3.5 16 12 20.5 20.5 16" />
    </svg>
  );
}

Dependencies

None (React + Tailwind only).

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →