No-JS FAQ Accordion
Exclusive accordion behaviour from named <details> elements — no state, no hooks, no JavaScript, full keyboard support for free.
Questions on the right against a sticky rail that keeps a route to a human in view — for the visitor whose objection is not on the list.
components/faq-two-column.tsxnpx hoverlab add faq-two-column
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
The things people ask before they buy — and a way to ask the ones that are not here.
Still not sure?
Send the question. A person reads it, and you get an answer rather than a link to this page.
Ask a questionNothing stops working. What ends is access to components published after your window — everything you already copied stays yours to ship, permanently, with no check at runtime.
Yes, on any paid plan, including projects you hand over to the client afterwards. The one thing no licence covers is reselling the components themselves as a competing library.
No. Attribution is never required, on any plan, including the free one.
There is, priced per seat, with shared brand tokens and one workspace everybody pulls from. It can be billed monthly or bought outright for a year.
Ask. Requests that several people want are usually the next thing built, and the roadmap is public.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <FaqTwoColumn> — questions on the right, a standing offer of help on the left.
*
* <FaqAccordion> answers the questions it was written for and has nothing
* to say about the one it wasn't. That gap is where the sale is lost: a
* visitor with an unlisted objection reads to the bottom, finds no way to
* ask, and leaves. The left rail exists to catch exactly that person, which
* is why it holds a contact route rather than decoration.
*
* The rail is `sticky` so it stays beside the questions however far the
* list runs — an offer of help that has scrolled out of view is one the
* reader has to go looking for at the moment they are least inclined to.
* On narrow screens it becomes a normal block above the list, because a
* sticky element in a single-column flow just eats the viewport.
*
* Disclosure is <details>/<summary> with a shared `name`, same as the
* accordion: real one-at-a-time behaviour, keyboard support and correct
* screen-reader state, with no state hook and no JavaScript.
*/
import * as React from 'react'
import { ChevronDown, MessageCircle } from 'lucide-react'
export interface FaqTwoColumnItem {
question: string
answer: string
}
export interface FaqTwoColumnProps {
items?: FaqTwoColumnItem[]
heading?: string
subheading?: string
/** Text on the help card. Pass null to drop the card entirely. */
helpTitle?: string | null
helpBody?: string
helpCtaLabel?: string
helpCtaHref?: string
/** Only one answer open at a time. */
exclusive?: boolean
className?: string
}
const DEFAULT_ITEMS: FaqTwoColumnItem[] = [
{
question: 'What happens when my licence expires?',
answer:
'Nothing stops working. What ends is access to components published after your window — everything you already copied stays yours to ship, permanently, with no check at runtime.',
},
{
question: 'Can I use this for client work?',
answer:
'Yes, on any paid plan, including projects you hand over to the client afterwards. The one thing no licence covers is reselling the components themselves as a competing library.',
},
{
question: 'Do I need to credit you?',
answer:
'No. Attribution is never required, on any plan, including the free one.',
},
{
question: 'Is there a team plan?',
answer:
'There is, priced per seat, with shared brand tokens and one workspace everybody pulls from. It can be billed monthly or bought outright for a year.',
},
{
question: 'What if I need something that is not in the catalog?',
answer:
'Ask. Requests that several people want are usually the next thing built, and the roadmap is public.',
},
]
/*
A stable id suffix derived from this instance's own content.
`useId` is the right answer and is not available here: this is a server
component and hooks are not. Hashing what makes one copy different from
another gives each its own name without a hook, a prop or a counter — and
it stays stable across server and client renders, which a counter would
not.
*/
function instanceId(...parts: (string | undefined)[]): string {
const text = parts.filter(Boolean).join('|')
let hash = 0
for (let i = 0; i < text.length; i++) hash = (Math.imul(hash, 31) + text.charCodeAt(i)) | 0
return (hash >>> 0).toString(36).slice(0, 6)
}
export function FaqTwoColumn({
items = DEFAULT_ITEMS,
heading = 'Questions, answered',
subheading = 'The things people ask before they buy — and a way to ask the ones that are not here.',
helpTitle = 'Still not sure?',
helpBody =
'Send the question. A person reads it, and you get an answer rather than a link to this page.',
helpCtaLabel = 'Ask a question',
helpCtaHref = '/contact',
exclusive = true,
className = '',
}: FaqTwoColumnProps) {
/*
* The exclusive-<details> group name, derived rather than written down.
*
* `name` on <details> is DOCUMENT-global: every <details> sharing it is
* one accordion, wherever on the page it sits. A literal therefore fuses
* two copies of this block into a single accordion — opening a question
* in one silently closes a question in the other — and a page rendering
* several of these at once is exactly what the catalog hubs do.
*/
const group = `faq-two-column-${instanceId(heading, subheading, items[0]?.question)}`
return (
<section
className={`mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 sm:py-24 ${className}`}
>
<div className="grid gap-10 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:gap-16">
{/* The rail. `self-start` is what lets `sticky` have an effect — a
stretched grid item is already as tall as the row, so it has no
room to move within it. */}
<div className="lg:sticky lg:top-24 lg:self-start">
<h2 className="text-balance text-3xl font-bold tracking-tight sm:text-4xl">
{heading}
</h2>
{subheading ? (
<p className="mt-3 text-pretty text-muted-foreground">{subheading}</p>
) : null}
{helpTitle ? (
<div className="mt-8 rounded-2xl border border-border/60 bg-card/60 p-5">
<MessageCircle
aria-hidden
className="h-5 w-5 text-muted-foreground"
/>
<p className="mt-3 font-semibold">{helpTitle}</p>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
{helpBody}
</p>
<a
href={helpCtaHref}
className="mt-4 inline-flex text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{helpCtaLabel}
</a>
</div>
) : null}
</div>
<div className="divide-y divide-border/60 border-y border-border/60">
{items.map((item) => (
<details
key={item.question}
name={exclusive ? group : undefined}
className="group [&_summary::-webkit-details-marker]:hidden"
>
<summary className="flex cursor-pointer list-none items-start justify-between gap-6 py-5 text-start text-base font-semibold transition-colors hover:text-foreground/80">
{item.question}
<ChevronDown
aria-hidden
className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-180"
/>
</summary>
<p className="max-w-prose pb-5 text-sm leading-relaxed text-muted-foreground">
{item.answer}
</p>
</details>
))}
</div>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/faq-two-column.tsx and import it where you need the section:
import { FaqTwoColumn } from '@/components/faq-two-column'4 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
items | FaqTwoColumnItem[] | DEFAULT_ITEMS |
heading | string | 'Questions, answered' |
subheading | string | — |
helpTitleText on the help card. Pass null to drop the card entirely. | string | null | 'Still not sure?' |
helpBody | string | — |
helpCtaLabel | string | 'Ask a question' |
helpCtaHref | string | '/contact' |
exclusiveOnly one answer open at a time. | boolean | true |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
<!--
Two-Column FAQ with Help Rail — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block has no interactive behaviour, so nothing is missing.
-->
<section class="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 sm:py-24 ">
<div class="grid gap-10 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:gap-16">
<div class="lg:sticky lg:top-24 lg:self-start">
<h2 class="text-balance text-3xl font-bold tracking-tight sm:text-4xl">Questions, answered</h2>
<p class="mt-3 text-pretty text-muted-foreground">The things people ask before they buy — and a way to ask the ones that are not here.</p>
<div class="mt-8 rounded-2xl border border-border/60 bg-card/60 p-5">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle h-5 w-5 text-muted-foreground" aria-hidden="true">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path>
</svg>
<p class="mt-3 font-semibold">Still not sure?</p>
<p class="mt-1 text-sm leading-relaxed text-muted-foreground">Send the question. A person reads it, and you get an answer rather than a link to this page.</p>
<a href="/contact" class="mt-4 inline-flex text-sm font-medium text-primary underline-offset-4 hover:underline">Ask a question</a>
</div>
</div>
<div class="divide-y divide-border/60 border-y border-border/60">
<details name="faq-two-column-1tb2ai" class="group [&amp;_summary::-webkit-details-marker]:hidden">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-5 text-start text-base font-semibold transition-colors hover:text-foreground/80">What happens when my licence expires?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-180" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></summary>
<p class="max-w-prose pb-5 text-sm leading-relaxed text-muted-foreground">Nothing stops working. What ends is access to components published after your window — everything you already copied stays yours to ship, permanently, with no check at runtime.</p>
</details>
<details name="faq-two-column-1tb2ai" class="group [&amp;_summary::-webkit-details-marker]:hidden">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-5 text-start text-base font-semibold transition-colors hover:text-foreground/80">Can I use this for client work?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-180" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></summary>
<p class="max-w-prose pb-5 text-sm leading-relaxed text-muted-foreground">Yes, on any paid plan, including projects you hand over to the client afterwards. The one thing no licence covers is reselling the components themselves as a competing library.</p>
</details>
<details name="faq-two-column-1tb2ai" class="group [&amp;_summary::-webkit-details-marker]:hidden">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-5 text-start text-base font-semibold transition-colors hover:text-foreground/80">Do I need to credit you?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-180" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></summary>
<p class="max-w-prose pb-5 text-sm leading-relaxed text-muted-foreground">No. Attribution is never required, on any plan, including the free one.</p>
</details>
<details name="faq-two-column-1tb2ai" class="group [&amp;_summary::-webkit-details-marker]:hidden">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-5 text-start text-base font-semibold transition-colors hover:text-foreground/80">Is there a team plan?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-180" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></summary>
<p class="max-w-prose pb-5 text-sm leading-relaxed text-muted-foreground">There is, priced per seat, with shared brand tokens and one workspace everybody pulls from. It can be billed monthly or bought outright for a year.</p>
</details>
<details name="faq-two-column-1tb2ai" class="group [&amp;_summary::-webkit-details-marker]:hidden">
<summary class="flex cursor-pointer list-none items-start justify-between gap-6 py-5 text-start text-base font-semibold transition-colors hover:text-foreground/80">What if I need something that is not in the catalog?<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-transform duration-200 group-open:rotate-180" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg></summary>
<p class="max-w-prose pb-5 text-sm leading-relaxed text-muted-foreground">Ask. Requests that several people want are usually the next thing built, and the roadmap is public.</p>
</details>
</div>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse FAQCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
Exclusive accordion behaviour from named <details> elements — no state, no hooks, no JavaScript, full keyboard support for free.
Every answer rendered and visible in a two-column description list — the FAQ shape for a marketing page, where collapsed text is text a search engine will not quote.
Anchor-linked topic pills over per-topic details groups with the first answer open — for the FAQ that outgrew a single list, working with JavaScript disabled.
A search that narrows the list across answers and keywords, announces the count, and turns an empty result into the contact route it should always have been.
An FAQ written from the objections that stop a sale rather than from the questions that are comfortable to answer.
The security questions a buyer's IT reviewer asks, answered with scope and dates rather than with the word "enterprise-grade".