Skip to main content

ns-ui

FAQ Accordion

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.

Usage

registry/core/faq-answer-depth-gutter/demo.tsx
"use client";

import { FaqDepthGutter, type FaqDepthItem } from "./component";

const ITEMS: FaqDepthItem[] = [
  {
    id: "trial",
    question: "Is there a free trial?",
    answer: <p>Fourteen days on any plan, no card required.</p>,
  },
  {
    id: "seats",
    question: "How does seat billing work when my team changes mid-month?",
    answer: (
      <p>
        Seats are counted daily and billed at the end of the cycle, so adding
        someone on the twentieth costs a third of a seat rather than a full one.
        Removing a seat credits the remainder against your next invoice.
      </p>
    ),
  },
  {
    id: "data",
    question: "Where is my data stored, and who can reach it?",
    answer: (
      <>
        <p>
          Primary storage is in eu-central-1, with encrypted backups replicated
          to eu-west-1. Nothing leaves the European Union at rest or in transit.
        </p>
        <p>
          Access is scoped per project. Support engineers cannot read project
          contents without a time-boxed grant that you approve in the audit
          panel, and every grant is logged with the ticket that justified it.
        </p>
        <p>
          Deleting a project removes it from primary storage immediately and
          from backups within thirty days, which is the window the retention
          policy holds for point-in-time recovery.
        </p>
      </>
    ),
  },
  {
    id: "sso",
    question: "Do you support SAML single sign-on?",
    answer: (
      <p>
        SAML 2.0 and SCIM provisioning ship on the Business plan and above.
        Okta, Entra ID and Google Workspace are covered by setup guides.
      </p>
    ),
  },
  {
    id: "cancel",
    question: "Can I cancel at any time?",
    answer: <p>Yes. Access runs to the end of the paid period.</p>,
  },
];

export default function FaqDepthGutterDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / faq-answer-depth-gutter
      </p>

      <div className="w-full max-w-2xl">
        {/* One answer open at rest, so the idle frame reads as an FAQ. */}
        <FaqDepthGutter items={ITEMS} defaultOpen={["data"]} />
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        An FAQ accordion. One answer is open at a time.
      </p>
    </div>
  );
}

Component

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 --ns-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 {
  /** the question/answer pairs, in order */
  items: FaqDepthItem[];
  /** Allow more than one answer open at once. Default false (single-open). */
  allowMultiple?: boolean;
  /** Item ids open on first render. */
  defaultOpen?: string[];
  /** extra classes merged onto the rendered root element */
  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(--ns-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(--ns-accent);}
.ns-fdg-trigger:focus-visible{outline:2px solid var(--ns-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(--ns-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(--ns-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;}
}
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/faq-answer-depth-gutter.tsx, so point the import there.

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.

Install

npx shadcn add https://design.helpmarq.com/r/faq-answer-depth-gutter.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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 --ns-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 --ns-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, --ns-muted for answers and the plus/minus sign, --ns-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.

Props

PropTypeDefaultDescription
itemsFaqDepthItem[]—the question/answer pairs, in order
allowMultiple?booleanfalseAllow more than one answer open at once. Default false (single-open).
defaultOpen?string[][]Item ids open on first render.
className?string—extra classes merged onto the rendered root element