FAQ Accordion

Faq

A plain FAQ accordion: real button semantics, one answer open at a time, a smooth height transition and a thin accent rail marking the open question.

Install
npx shadcn add https://design.helpmarq.com/r/faq-answer-depth-gutter.json
Source
registry/core/faq-answer-depth-gutter/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type ReactNode,
  type TransitionEvent as ReactTransitionEvent,
} from "react";

// ---------------------------------------------------------------------------
// FaqDepthGutter — a plain FAQ accordion.
//
// Real button semantics (Tab/Enter/Space are native, no keydown handler),
// aria-expanded on the trigger, aria-controls -> a role=region panel labelled
// by the trigger. Opening eases the panel 0 -> scrollHeight, then pins it to
// height:auto on transitionend so later reflow stays correct; closing pins
// auto back to px, forces a reflow and eases to 0, and `hidden` goes on only
// after that ends, so a screen reader never sees a half-collapsed region.
// A 2px --accent rail marks the open row. prefers-reduced-motion toggles
// height with no transition. Tokens only, zero dependencies.
// ---------------------------------------------------------------------------

const OPEN_MS = 260;
const CLOSE_MS = 200;
const EASE = "cubic-bezier(0.22,1,0.36,1)";

export interface FaqDepthItem {
  id: string;
  question: string;
  answer: ReactNode;
}

export interface FaqDepthGutterProps {
  items: FaqDepthItem[];
  /** Allow more than one answer open at once. Default false (single-open). */
  allowMultiple?: boolean;
  /** Item ids open on first render. */
  defaultOpen?: string[];
  className?: string;
}

function prefersReduced() {
  return (
    typeof window !== "undefined" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

export function FaqDepthGutter({
  items,
  allowMultiple = false,
  defaultOpen = [],
  className = "",
}: FaqDepthGutterProps) {
  const baseId = useId().replace(/:/g, "");
  const [openIds, setOpenIds] = useState<Set<string>>(() => new Set(defaultOpen));

  const panelRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const initialized = useRef<Set<string>>(new Set());
  const prevOpen = useRef<Set<string>>(new Set(defaultOpen));
  const openRef = useRef(openIds);

  openRef.current = openIds;

  const expand = useCallback((id: string) => {
    const el = panelRefs.current.get(id);
    if (!el) return;
    el.hidden = false;
    el.style.transition = "none";
    el.style.height = "0px";
    void el.offsetHeight; // forced reflow: commit the start height
    if (prefersReduced()) {
      el.style.transition = "";
      el.style.height = "auto";
      return;
    }
    el.style.transition = `height ${OPEN_MS}ms ${EASE}`;
    el.style.height = `${el.scrollHeight}px`;
  }, []);

  const collapse = useCallback((id: string) => {
    const el = panelRefs.current.get(id);
    if (!el) return;
    el.style.transition = "none";
    el.style.height = `${el.offsetHeight}px`; // pin height:auto back to px
    void el.offsetHeight; // forced reflow before easing down
    if (prefersReduced()) {
      el.style.transition = "";
      el.style.height = "0px";
      el.hidden = true;
      return;
    }
    el.style.transition = `height ${CLOSE_MS}ms ${EASE}`;
    el.style.height = "0px";
  }, []);

  useEffect(() => {
    openIds.forEach((id) => {
      if (!prevOpen.current.has(id)) expand(id);
    });
    prevOpen.current.forEach((id) => {
      if (!openIds.has(id)) collapse(id);
    });
    prevOpen.current = new Set(openIds);
  }, [openIds, expand, collapse]);

  const onPanelTransitionEnd = (e: ReactTransitionEvent<HTMLDivElement>, id: string) => {
    if (e.propertyName !== "height" || e.target !== e.currentTarget) return;
    const el = panelRefs.current.get(id);
    if (!el) return;
    el.style.transition = "";
    if (openRef.current.has(id)) {
      // auto, not px: later reflow or a text-wrap change stays correct
      el.style.height = "auto";
    } else {
      el.style.height = "0px";
      el.hidden = true;
    }
  };

  const toggle = (id: string) => {
    setOpenIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        if (!allowMultiple) next.clear();
        next.add(id);
      }
      return next;
    });
  };

  return (
    <div className={`ns-fdg-root ${className}`.trim()}>
      <style>{CSS}</style>

      {items.map((item) => {
        const isOpen = openIds.has(item.id);
        const triggerId = `${baseId}-q-${item.id}`;
        const panelId = `${baseId}-a-${item.id}`;

        return (
          <div key={item.id} className="ns-fdg-row" data-open={isOpen}>
            <h3 className="ns-fdg-h3">
              <button
                type="button"
                id={triggerId}
                data-faq-trigger=""
                className="ns-fdg-trigger"
                aria-expanded={isOpen}
                aria-controls={panelId}
                onClick={() => toggle(item.id)}
              >
                <span className="ns-fdg-question">{item.question}</span>
                <span className="ns-fdg-sign" data-open={isOpen} aria-hidden="true">
                  <span className="ns-fdg-sign-h" />
                  <span className="ns-fdg-sign-v" />
                </span>
              </button>
            </h3>

            <div
              id={panelId}
              ref={(el) => {
                if (!el) {
                  panelRefs.current.delete(item.id);
                  return;
                }
                panelRefs.current.set(item.id, el);
                if (initialized.current.has(item.id)) return;
                initialized.current.add(item.id);
                if (openIds.has(item.id)) {
                  el.style.height = "auto";
                } else {
                  el.style.height = "0px";
                  el.hidden = true;
                }
              }}
              data-faq-panel=""
              data-open={isOpen}
              role="region"
              aria-labelledby={triggerId}
              className="ns-fdg-panel"
              onTransitionEnd={(e) => onPanelTransitionEnd(e, item.id)}
            >
              <div className="ns-fdg-answer">{item.answer}</div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

const CSS = `
.ns-fdg-root{width:100%;color:var(--foreground);padding-left:14px;}
.ns-fdg-root *,.ns-fdg-root *::before,.ns-fdg-root *::after{box-sizing:border-box;}

.ns-fdg-row{position:relative;}
.ns-fdg-row + .ns-fdg-row{border-top:1px solid var(--border);}
/* Open-row rail: fixed height, so two open rows never join into one bar. */
.ns-fdg-row::before{
  content:"";position:absolute;left:-14px;top:18px;width:2px;height:18px;
  border-radius:9999px;background-color:transparent;
  transition:background-color 160ms ease;
}
.ns-fdg-row[data-open="true"]::before{background-color:var(--accent);}

.ns-fdg-h3{margin:0;font-size:inherit;font-weight:inherit;}
.ns-fdg-trigger{
  display:flex;width:100%;align-items:center;justify-content:space-between;gap:20px;
  padding:16px 0;border:0;background:none;
  color:var(--foreground);font:inherit;font-size:15px;line-height:1.4;
  text-align:left;cursor:pointer;border-radius:6px;
}
.ns-fdg-trigger:hover .ns-fdg-question{color:var(--accent);}
.ns-fdg-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
.ns-fdg-question{min-width:0;transition:color 160ms ease;}

.ns-fdg-sign{position:relative;flex:0 0 10px;width:10px;height:10px;}
.ns-fdg-sign-h,.ns-fdg-sign-v{position:absolute;left:0;top:0;background-color:var(--muted);}
.ns-fdg-sign-h{width:10px;height:1.5px;top:4.25px;}
.ns-fdg-sign-v{width:1.5px;height:10px;left:4.25px;transition:transform 200ms ${EASE};transform-origin:center;}
.ns-fdg-sign[data-open="true"] .ns-fdg-sign-v{transform:scaleY(0);}

.ns-fdg-panel{overflow:hidden;height:0;}
.ns-fdg-panel[hidden]{display:none;}
.ns-fdg-answer{padding:0 0 20px 0;color:var(--muted);font-size:13.5px;line-height:1.65;}
.ns-fdg-answer p{margin:0;}
.ns-fdg-answer p + p{margin-top:11px;}

@media (prefers-reduced-motion: reduce){
  .ns-fdg-sign-v,.ns-fdg-question{transition-duration:0ms !important;}
}
`;
Use when

Pick faq-answer-depth-gutter for a straightforward list of questions and answers on a marketing or docs page. accordion-latch is the better pick when the closed state should read as mechanically shut, and truncation-word-count is for expanding a single clamped block of prose in place rather than a list of questions.

Build spec

Build <FaqDepthGutter items allowMultiple? defaultOpen? className?> where items is {id, question, answer: ReactNode}[]. STRUCTURE: each item is a row containing a real <button data-faq-trigger aria-expanded aria-controls={panelId}> inside an <h3>, plus a <div data-faq-panel data-open role="region" aria-labelledby={triggerId}> panel holding the answer. Rows are separated by a single 1px --border rule on `.row + .row`, and every rule shares one left edge — nothing is inset per state. OPEN/CLOSE: opening sets the panel hidden=false, height:0, forces a reflow (void el.offsetHeight) and eases height to el.scrollHeight over OPEN_MS 260ms cubic-bezier(0.22,1,0.36,1); on transitionend the height is set to auto so later reflow or a text-wrap change stays correct. Closing pins auto back to the measured px, forces a reflow, eases to 0 over CLOSE_MS 200ms, and applies `hidden` only after that transition ends so a screen reader never sees a half-collapsed region. allowMultiple defaults to false: opening one closes the previous. INDICATOR: the open row carries a 2px x 18px --accent rail in the root's left padding, absolutely positioned at a fixed offset so adjacent open rows always render as two discrete marks, never one continuous bar. No measurement, no offscreen mirror, no ResizeObserver, no per-item bar chart. ACCESSIBILITY: real button semantics, so Tab/Enter/Space are native and there is no keydown handler; aria-expanded on the trigger, aria-controls to the panel id, aria-labelledby back to the trigger; data-open exposed on both row and panel for probing; a visible :focus-visible outline in --accent at 3px offset. REDUCED MOTION: prefers-reduced-motion (checked via matchMedia at toggle time) toggles the panel between 0 and auto with no transition, so nothing can be left stuck mid-animation. TOKENS ONLY: --border for the row rules, --foreground for questions, --muted for answers and the plus/minus sign, --accent for the open-row rail, the :focus-visible ring and the hovered question; both themes come out of the same variables. REST STATE: the demo mounts with one answer open, so the idle frame reads unmistakably as an FAQ. Zero dependencies.

Tags
faqaccordiondisclosuresectionmarketing