Web InnoventixFreeCode

Category Tabs Accordion

Original · free

Category tabs that switch between grouped accordion sets.

byWeb InnoventixReact + Tailwind
accordioncategorytabsaccordions
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-category-tabs.json
accordion-category-tabs.tsx
"use client";

import { useId, useRef, useState, type KeyboardEvent } from "react";

type QA = { q: string; a: string };
type Category = { id: string; label: string; blurb: string; items: QA[] };

const CATEGORIES: Category[] = [
  {
    id: "billing",
    label: "Billing",
    blurb: "Plans, invoices, refunds and payment methods.",
    items: [
      {
        q: "When am I charged for my subscription?",
        a: "Your card is billed on the calendar day you first upgraded, then on that same day each month or year. If the day doesn't exist in a given month (say the 31st), we bill on the last day of that month instead.",
      },
      {
        q: "Can I switch from monthly to annual mid-cycle?",
        a: "Yes. We credit the unused portion of your current month against the annual price, so you only pay the difference. The switch takes effect immediately and your renewal date resets to the day you changed.",
      },
      {
        q: "What happens if a payment fails?",
        a: "We retry the charge three times over five days and email you before each attempt. Your workspace stays fully active during that window. If all retries fail, we downgrade you to the free tier rather than deleting anything.",
      },
      {
        q: "Do you offer refunds?",
        a: "We refund annual plans in full within 14 days of purchase, no questions asked. Monthly plans aren't refundable, but you can cancel anytime and keep access until the period ends.",
      },
    ],
  },
  {
    id: "account",
    label: "Account",
    blurb: "Sign-in, security and team seats.",
    items: [
      {
        q: "How do I reset my password?",
        a: "Choose \"Forgot password\" on the sign-in screen and we'll email a reset link that stays valid for one hour. If you use single sign-on, reset the password with your identity provider instead — we never store it.",
      },
      {
        q: "Can I enable two-factor authentication?",
        a: "Go to Settings → Security and turn on 2FA using any authenticator app. We'll show ten one-time recovery codes — save them somewhere safe, because they're the only way back in if you lose your device.",
      },
      {
        q: "How do I add teammates to my workspace?",
        a: "Open Members, enter their work email and pick a role. They'll receive an invite that expires after seven days. Seats are added to your plan automatically and prorated for the rest of the billing period.",
      },
    ],
  },
  {
    id: "shipping",
    label: "Shipping",
    blurb: "Delivery windows, tracking and returns.",
    items: [
      {
        q: "How long does standard delivery take?",
        a: "Standard orders arrive in 3–5 business days within the contiguous US. We pack and hand off same-day for anything placed before 2 PM local time on a weekday.",
      },
      {
        q: "Do you ship internationally?",
        a: "We ship to 42 countries. Duties and import taxes are calculated at checkout so there are no surprise fees on delivery. Transit usually takes 7–12 business days depending on customs.",
      },
      {
        q: "How do I start a return?",
        a: "Head to Orders, select the item and choose \"Return\". We email a prepaid label and refund you the moment the carrier scans the package — you don't have to wait for it to reach our warehouse.",
      },
    ],
  },
];

export default function AccordionCategoryTabs() {
  const uid = useId();
  const [activeTab, setActiveTab] = useState<string>(CATEGORIES[0].id);
  const [openItem, setOpenItem] = useState<string | null>(`${CATEGORIES[0].id}-0`);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const active = CATEGORIES.find((c) => c.id === activeTab) ?? CATEGORIES[0];

  function selectTab(id: string) {
    setActiveTab(id);
    setOpenItem(`${id}-0`);
  }

  function onTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
    const last = CATEGORIES.length - 1;
    let next = index;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = index === last ? 0 : index + 1;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = index === 0 ? last : index - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = last;
    else return;
    e.preventDefault();
    const target = CATEGORIES[next];
    selectTab(target.id);
    tabRefs.current[next]?.focus();
  }

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 dark:bg-slate-950">
      <style>{`
        @keyframes actabs-panel-in {
          from { opacity: 0; transform: translateY(8px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes actabs-body-in {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        .actabs-panel { animation: actabs-panel-in .35s cubic-bezier(.16,1,.3,1) both; }
        .actabs-body-inner { animation: actabs-body-in .4s ease both; }
        @media (prefers-reduced-motion: reduce) {
          .actabs-panel, .actabs-body-inner { animation: none !important; }
          .actabs-grid { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="mb-8 text-center">
          <span className="inline-block rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold tracking-wide text-indigo-700 uppercase dark:bg-indigo-500/15 dark:text-indigo-300">
            Help center
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Frequently asked questions
          </h2>
          <p className="mt-3 text-base text-slate-600 dark:text-slate-400">
            Browse by category. Everything you need, grouped and answered.
          </p>
        </div>

        {/* Tabs */}
        <div
          role="tablist"
          aria-label="FAQ categories"
          aria-orientation="horizontal"
          className="mb-6 flex flex-wrap justify-center gap-2"
        >
          {CATEGORIES.map((cat, i) => {
            const selected = cat.id === activeTab;
            return (
              <button
                key={cat.id}
                ref={(el) => {
                  tabRefs.current[i] = el;
                }}
                role="tab"
                id={`${uid}-tab-${cat.id}`}
                aria-selected={selected}
                aria-controls={`${uid}-panel-${cat.id}`}
                tabIndex={selected ? 0 : -1}
                onClick={() => selectTab(cat.id)}
                onKeyDown={(e) => onTabKeyDown(e, i)}
                className={`rounded-full px-4 py-2 text-sm font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                  selected
                    ? "bg-indigo-600 text-white shadow-sm shadow-indigo-600/30"
                    : "bg-white text-slate-600 ring-1 ring-slate-200 ring-inset hover:bg-slate-100 hover:text-slate-900 dark:bg-slate-900 dark:text-slate-400 dark:ring-slate-800 dark:hover:bg-slate-800 dark:hover:text-white"
                }`}
              >
                {cat.label}
              </button>
            );
          })}
        </div>

        {/* Panel */}
        <div
          key={active.id}
          role="tabpanel"
          id={`${uid}-panel-${active.id}`}
          aria-labelledby={`${uid}-tab-${active.id}`}
          tabIndex={0}
          className="actabs-panel rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
        >
          <p className="mb-4 px-1 text-sm text-slate-500 dark:text-slate-400">{active.blurb}</p>

          <div className="space-y-3">
            {active.items.map((item, idx) => {
              const key = `${active.id}-${idx}`;
              const isOpen = openItem === key;
              return (
                <div
                  key={key}
                  className={`overflow-hidden rounded-xl border bg-white transition-colors dark:bg-slate-900 ${
                    isOpen
                      ? "border-indigo-300 dark:border-indigo-500/40"
                      : "border-slate-200 dark:border-slate-800"
                  }`}
                >
                  <h3>
                    <button
                      type="button"
                      id={`${uid}-btn-${key}`}
                      aria-expanded={isOpen}
                      aria-controls={`${uid}-region-${key}`}
                      onClick={() => setOpenItem(isOpen ? null : key)}
                      className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
                    >
                      <span className="text-[0.95rem] font-semibold text-slate-900 dark:text-white">
                        {item.q}
                      </span>
                      <span
                        aria-hidden="true"
                        className={`grid size-6 shrink-0 place-items-center rounded-full text-slate-500 transition-transform duration-300 dark:text-slate-400 ${
                          isOpen ? "rotate-45 text-indigo-600 dark:text-indigo-400" : ""
                        }`}
                      >
                        <svg
                          viewBox="0 0 24 24"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="2"
                          strokeLinecap="round"
                          className="size-4"
                        >
                          <path d="M12 5v14M5 12h14" />
                        </svg>
                      </span>
                    </button>
                  </h3>
                  <div
                    id={`${uid}-region-${key}`}
                    role="region"
                    aria-labelledby={`${uid}-btn-${key}`}
                    className="actabs-grid grid transition-[grid-template-rows] duration-300 ease-out"
                    style={{ gridTemplateRows: isOpen ? "1fr" : "0fr" }}
                  >
                    <div className="overflow-hidden">
                      <p className="actabs-body-inner px-5 pb-5 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                        {item.a}
                      </p>
                    </div>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </section>
  );
}

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 →