Underline Accordion
Original · freeAccordion where the active header gets an animated underline.
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"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
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 quoteSimilar components
Browse all →
Simple Accordion
OriginalA clean single-column accordion, one panel open at a time, chevron rotates.

Multi Accordion
OriginalAn accordion where multiple panels can be open at once.

Plus Minus Accordion
OriginalAccordion with a plus/minus icon that toggles per item.

Bordered Accordion
OriginalBordered card-style accordion panels with rounded corners.

Filled Accordion
OriginalFilled, tinted accordion panels; the active one is highlighted.

FAQ Two Col Accordion
OriginalA two-column FAQ accordion layout.

Numbered Accordion
OriginalA numbered-step accordion (01, 02, 03...).
Icon Left Accordion
OriginalAccordion with a leading icon per item and a right chevron.

Gradient Active Accordion
OriginalAccordion where the open panel gets a soft gradient background.

Nested Accordion
OriginalAn accordion with nested sub-accordions.

Flush Accordion
OriginalA borderless, flush accordion separated by hairlines.

Pill Accordion
OriginalAccordion with rounded pill-shaped headers.

