Skip to content
Motion
FreeIn reviewv1.0.0

Details FAQ

Questions on native details elements: keyboard and screen-reader friendly, and working without JavaScript.

npx shadcn@latest add @vibebox/faq-details

Controls

Variant
<FaqDetails />

About

Rebuilds the site kit's FAQ. The free default (and only) FAQ block: native details do the job better than any script. Visible questions also feed FAQPage JSON-LD in assembled apps.

Use it when

  • Customers ask the same things before they book or buy.

Skip it when

  • There are fewer than four real questions: answer them in the services copy.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the block with the shadcn CLI:
    npx shadcn@latest add @vibebox/faq-details
  3. 3Use it:
    import { FaqDetails } from "@/components/vibebox/faq-details";
    import { home } from "@/content/pages/home";
    
    <FaqDetails content={home.faq} variant="split" id="faq" />

Code

2 files
components/vibebox/faq-details/index.tsx
/**
 * VibeBox Motion · Details FAQ
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { Container, headingId, Icon, REVEAL, RichTextView, Section, SectionHead, SlotText, TextLink, type BlockProps } from "@/lib/vibebox/block-kit";
import { cn } from "@/lib/utils";
import type { Content, Variant } from "./schema";

/*
 * Questions on native <details>: they open and close without JavaScript, by keyboard and screen
 * reader, and the first one starts open. Answers are rich text and can hold a fill-in for a fact
 * still to come.
 */

export type FaqDetailsProps = BlockProps<Content, Variant>;

export function FaqDetails({ content, variant = "split", id, tone, className }: FaqDetailsProps) {
  const split = variant === "split";
  return (
    <Section slug="faq-details" variant={variant} id={id} tone={tone} labelledBy={headingId(id)} className={className}>
      <Container className={cn("grid gap-[clamp(32px,4vw,56px)]", split && "lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-20")}>
        <SectionHead eyebrow={content.eyebrow} heading={content.heading} lead={content.lead} id={id} align={split ? "start" : "center"} className={cn(split && "lg:sticky lg:top-24 lg:self-start")}>
          {content.action && <TextLink link={content.action} path="action" className="mt-1" />}
        </SectionHead>
        <div className={cn("grid border-t border-border", !split && "mx-auto w-full max-w-3xl")}>
          {content.items.map((item, index) => (
            <details key={index} open={index === 0} className={cn("group/faq border-b border-border", REVEAL)}>
              <summary className="flex cursor-pointer list-none items-start justify-between gap-6 py-5 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden">
                <SlotText value={item.question} path={`items.${index}.question`} className="text-[1.075rem] font-semibold tracking-tight" />
                <span aria-hidden="true" className="mt-0.5 inline-grid size-7 shrink-0 place-items-center rounded-full ring-1 ring-border-strong transition-transform duration-(--vb-duration-reveal) ease-expo-out group-open/faq:rotate-45">
                  <Icon name="plus" className="size-4" />
                </span>
              </summary>
              <RichTextView value={item.answer} path={`items.${index}.answer`} className="max-w-[62ch] pr-12 pb-6 leading-relaxed text-pretty text-muted-foreground" />
            </details>
          ))}
        </div>
      </Container>
    </Section>
  );
}
components/vibebox/faq-details/schema.ts
/**
 * VibeBox Motion · Details FAQ
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { sections, type z } from "@/lib/vibebox/content";

export const variants = ["split", "stacked"] as const;

export type Variant = (typeof variants)[number];

export const content = sections.faq;

export type Content = z.infer<typeof content>;

Content

Typed slots from schema.ts. Facts (hours, prices, quotes, names) come only from the business; until then they render as fill-ins.

SlotLabelTypeLimitFact
eyebrowKickertext32 chars—
headingHeadingtext60 chars—
leadIntrotext160 chars—
itemsQuestionslistup to 8—
items[].questionQuestiontext100 chars—
items[].answerAnswerrichText400 chars—
actionButtonaction24 chars—

Variants

  • Splitdefault

    split

    Heading and action left (sticky on desktop), questions right.

  • Stacked

    stacked

    Centred heading, questions beneath in one column.

Accessibility and motion

Keyboard
Tab to a question; Enter or Space opens and closes it.
Screen readers
Native disclosures that announce expanded or collapsed; answers follow their questions.
Touch
Whole question rows are tap targets.
Reduced motion
The plus icon does not rotate.

Edge cases and limits

  • Eight 400-character answers: the page grows; questions stay scannable.
  • Answers never take HTML strings.
  • A tall horizon scene beside the story of a family dental clinic and three details

    The business in its own words: a tall photo with a caption beside the story and a few honest details.

    About block · Server

  • A headline about moving better beside a mosaic of a tile scene, a bloom scene and a class list

    The promise beside a three-tile mosaic: a tall photo, a second image and a card of what the business offers.

    Heroes block · Server

  • A tinted booking band with a heading, visit details and a Book online button

    When, how long and one clear booking action, on a tinted band or a centred card.

    Booking block · Server

↑ ↓ to move↵ to openesc to close