Web InnoventixFreeCode

Underline Accordion

Original · free

Accordion where the active header gets an animated underline.

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

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

type FaqItem = {
  question: string;
  answer: string;
};

const ITEMS: FaqItem[] = [
  {
    question: "How long does a typical website project take?",
    answer:
      "Most marketing sites ship in three to five weeks. A landing page can be live in a week; a larger multi-page build with custom animation and a CMS runs closer to eight. We lock the timeline in the kickoff call so there are no moving goalposts.",
  },
  {
    question: "Do you write the copy, or do I need to provide it?",
    answer:
      "Either works. We have in-house SEO copywriters who can draft everything from scratch, or we polish and structure the copy you already have. Bring us a rough outline and we handle the rest, including headlines, meta descriptions, and calls to action.",
  },
  {
    question: "Will the site actually rank on Google?",
    answer:
      "Every build ships with clean semantic HTML, fast Core Web Vitals, structured data, and a keyword-mapped page architecture. Ranking also depends on ongoing content and links, so we hand off a 90-day SEO roadmap you can run yourself or with us.",
  },
  {
    question: "What happens after the site goes live?",
    answer:
      "You get a 30-day support window for fixes and small tweaks at no cost, plus a recorded walkthrough of your CMS. After that you can self-manage, or move onto a monthly care plan that covers updates, monitoring, and monthly reporting.",
  },
  {
    question: "Can you work with my existing brand and design files?",
    answer:
      "Yes. Send us your logo, brand guide, and any Figma or design assets and we build directly on top of them. If you do not have a system yet, we can establish a lightweight one, colors, type scale, and spacing, as part of the build.",
  },
];

export default function AccordionUnderline() {
  const [openIndex, setOpenIndex] = useState<number | null>(0);
  const panelRefs = useRef<(HTMLDivElement | null)[]>([]);
  const baseId = useId();

  const toggle = (index: number) => {
    setOpenIndex((current) => (current === index ? null : index));
  };

  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    const count = ITEMS.length;
    if (event.key === "ArrowDown") {
      event.preventDefault();
      const next = (index + 1) % count;
      document.getElementById(`${baseId}-header-${next}`)?.focus();
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      const prev = (index - 1 + count) % count;
      document.getElementById(`${baseId}-header-${prev}`)?.focus();
    } else if (event.key === "Home") {
      event.preventDefault();
      document.getElementById(`${baseId}-header-0`)?.focus();
    } else if (event.key === "End") {
      event.preventDefault();
      document.getElementById(`${baseId}-header-${count - 1}`)?.focus();
    }
  };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 dark:bg-slate-950 sm:px-6 lg:px-8">
      <style>{`
        @keyframes acu-fade-in {
          from { opacity: 0; transform: translateY(-4px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .acu-answer { animation: acu-fade-in 320ms cubic-bezier(0.16, 1, 0.3, 1); }
        @media (prefers-reduced-motion: reduce) {
          .acu-answer { animation: none; }
          .acu-underline, .acu-chevron, .acu-panel { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="mb-10 text-center">
          <span className="inline-block rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
            FAQ
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            Questions, answered
          </h2>
          <p className="mt-3 text-base text-slate-600 dark:text-slate-400">
            Everything you need to know before we start building.
          </p>
        </div>

        <div className="divide-y divide-slate-200 rounded-2xl border border-slate-200 bg-white shadow-sm dark:divide-slate-800 dark:border-slate-800 dark:bg-slate-900">
          {ITEMS.map((item, index) => {
            const isOpen = openIndex === index;
            const headerId = `${baseId}-header-${index}`;
            const panelId = `${baseId}-panel-${index}`;

            return (
              <div key={item.question} className="px-5 sm:px-6">
                <h3>
                  <button
                    id={headerId}
                    type="button"
                    aria-expanded={isOpen}
                    aria-controls={panelId}
                    onClick={() => toggle(index)}
                    onKeyDown={(event) => onKeyDown(event, index)}
                    className="group flex w-full items-center justify-between gap-4 py-5 text-left outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                  >
                    <span className="relative inline-block">
                      <span
                        className={`text-base font-semibold transition-colors duration-200 sm:text-lg ${
                          isOpen
                            ? "text-indigo-600 dark:text-indigo-400"
                            : "text-slate-800 group-hover:text-slate-900 dark:text-slate-200 dark:group-hover:text-white"
                        }`}
                      >
                        {item.question}
                      </span>
                      <span
                        aria-hidden="true"
                        className={`acu-underline absolute -bottom-1 left-0 h-0.5 origin-left rounded-full bg-indigo-500 transition-transform duration-300 ease-out dark:bg-indigo-400 ${
                          isOpen ? "scale-x-100" : "scale-x-0 group-hover:scale-x-100 group-hover:bg-slate-300 dark:group-hover:bg-slate-600"
                        }`}
                        style={{ width: "100%" }}
                      />
                    </span>

                    <span
                      className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors duration-200 ${
                        isOpen
                          ? "border-indigo-200 bg-indigo-50 text-indigo-600 dark:border-indigo-500/30 dark:bg-indigo-500/15 dark:text-indigo-300"
                          : "border-slate-200 bg-slate-50 text-slate-500 group-hover:text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:group-hover:text-slate-200"
                      }`}
                    >
                      <svg
                        aria-hidden="true"
                        viewBox="0 0 20 20"
                        fill="none"
                        className={`acu-chevron h-4 w-4 transition-transform duration-300 ease-out ${
                          isOpen ? "rotate-180" : "rotate-0"
                        }`}
                      >
                        <path
                          d="M5 7.5 10 12.5 15 7.5"
                          stroke="currentColor"
                          strokeWidth="1.75"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        />
                      </svg>
                    </span>
                  </button>
                </h3>

                <div
                  id={panelId}
                  role="region"
                  aria-labelledby={headerId}
                  ref={(el) => {
                    panelRefs.current[index] = el;
                  }}
                  className="acu-panel grid overflow-hidden transition-all duration-300 ease-out"
                  style={{
                    gridTemplateRows: isOpen ? "1fr" : "0fr",
                    opacity: isOpen ? 1 : 0,
                  }}
                >
                  <div className="min-h-0">
                    {isOpen && (
                      <p className="acu-answer pb-5 pr-10 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                        {item.answer}
                      </p>
                    )}
                  </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 →