Block
FAQ section
An FAQ section in accordion, categorized columns, and live search variants.
- #arc
- #block
- #faq
- #accordion
- #search
- #help
- #section
- #illustration
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/faq-section.jsonFiles
components/arc/blocks/faq-section/faq-section.module.csscomponents/arc/blocks/faq-section/faq-section.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownfaq-section.module.cssfaq-section.module.cssfaq-section.module.csscss
.faq { container: faq / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.stack { display: grid; gap: var(--space-8); max-width: 720px; margin: 0 auto; padding: var(--space-20) var(--space-6); }
.intro { display: grid; gap: var(--space-3); }
.accordion .intro, .search .intro { justify-items: center; text-align: center; }
.title { margin: 0; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.description { max-width: 480px; margin: 0; color: var(--text-secondary); font-size: var(--text-base); line-height: var(--leading-body); text-wrap: pretty; }
.list { margin: 0; padding: 0; border-top: 1px solid var(--border); list-style: none; }
.item { overflow: hidden; border-bottom: 1px solid var(--border); }
.heading { margin: 0; font: inherit; }
.trigger { display: flex; width: 100%; min-height: 64px; align-items: center; justify-content: space-between; gap: var(--space-4); border: 0; padding: var(--space-4) var(--space-1); background: none; color: var(--foreground); font: inherit; font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.question { min-width: 0; }
.icon { flex: none; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }
.item[data-open] .icon { color: var(--foreground); transform: rotate(45deg); }
@media (hover: hover) and (pointer: fine) { .trigger:hover .icon { color: var(--foreground); } }
.panel { overflow: hidden; }
.answer { margin: 0; padding: 0 var(--space-10) var(--space-5) var(--space-1); color: var(--text-secondary); font-size: var(--text-base); line-height: 1.6; text-wrap: pretty; }
.hint { overflow: hidden; margin: 0; padding: 0 var(--space-10) var(--space-4) var(--space-1); color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); }
.mark { border-radius: 4px; padding: 0 1px; background: var(--accent-subtle); color: inherit; }
.contact { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--space-4); border: 1px solid var(--border); border-radius: 20px; padding: var(--space-4) var(--space-5); background: var(--surface); color: var(--foreground); font: inherit; text-align: left; text-decoration: none; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: transform var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.contact:active { transform: scale(.99); }
.contactText { display: grid; gap: 2px; min-width: 0; }
.contactText > span:first-child { font-size: var(--text-sm); font-weight: 500; }
.contactText > span:last-child:not(:first-child) { color: var(--text-muted); font-size: var(--text-sm); }
.contactArrow { flex: none; color: var(--text-muted); transition: transform var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) {
.contact:hover { background: var(--surface-muted); }
.contact:hover .contactArrow { color: var(--foreground); transform: translateX(2px); }
}
/* Two column: a topic rail beside the questions for that topic. */
.columnsGrid { display: grid; max-width: 1120px; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); align-items: start; gap: var(--space-16); margin: 0 auto; padding: var(--space-20) var(--space-8); }
.rail { position: sticky; top: var(--space-6); display: grid; gap: var(--space-6); }
.categories { display: grid; gap: 2px; }
.category { position: relative; isolation: isolate; display: flex; height: 40px; align-items: center; justify-content: space-between; gap: var(--space-3); border: 0; border-radius: 12px; padding: 0 var(--space-3); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; text-align: left; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
.category[aria-pressed="true"] { color: var(--foreground); }
@media (hover: hover) and (pointer: fine) { .category:hover { color: var(--foreground); } }
.categoryHighlight { position: absolute; z-index: -1; inset: 0; border-radius: inherit; background: var(--surface-muted); }
.count { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.stage { position: relative; min-width: 0; }
.stageFace { width: 100%; }
/* Searchable */
.searchRow { display: grid; gap: var(--space-2); }
.resultCount { margin: 0; color: var(--text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
.empty { display: grid; justify-items: center; gap: var(--space-3); padding: var(--space-6) 0; text-align: center; }
.empty p { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); }
.clear { height: var(--control-height-sm); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-4); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: transform var(--duration-fast) var(--ease-standard); }
.clear:active { transform: scale(.97); }
@container faq (min-width: 720px) { .title { font-size: var(--text-4xl); } }
@container faq (max-width: 760px) {
.columnsGrid { grid-template-columns: minmax(0, 1fr); gap: var(--space-8); padding-inline: var(--space-6); }
.rail { position: static; }
.categories { display: flex; overflow-x: auto; scrollbar-width: none; }
.categories::-webkit-scrollbar { display: none; }
.category { flex: none; }
}
@container faq (max-width: 480px) {
.stack, .columnsGrid { padding: var(--space-12) var(--space-4); }
.answer, .hint { padding-right: var(--space-2); }
}
.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: var(--background); }
@media (prefers-reduced-motion: reduce) {
.icon, .contact, .contactArrow, .category, .clear { transition: none; }
.contact:active, .clear:active { transform: none; }
}
/* The page-top illustration sits above the heading and takes the heading's width at most. */
.art { width: min(100%, 480px); margin: 0 auto calc(var(--space-2) * -1); border-radius: var(--radius-surface); }
faq-section.tsxfaq-section.tsxfaq-section.tsxtsx
"use client";
import { forwardRef, useId, useMemo, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import type { Variants } from "motion/react";
import { SearchField } from "../../search-field/search-field";
import SegmentedControl from "../../segmented-control/segmented-control";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./faq-section.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";
import { Illustration } from "../../illustration/illustration";
export type FaqSectionVariant = "accordion" | "columns" | "search";
export interface FaqItem {
/** Stable id used for open state. Defaults to the question. */
id?: string;
question: string;
/** Plain text answer; it is also what search matches. */
answer: string;
/** Groups questions in the columns variant. */
category?: string;
}
export interface FaqSectionProps {
/** `accordion` is one centered list, `columns` groups questions beside a category rail, `search` filters as you type. */
variant?: FaqSectionVariant;
title?: string;
description?: string;
items?: FaqItem[];
/** Allow several answers open at once. Defaults to false. */
multiple?: boolean;
/** Open item ids (controlled). */
value?: string[];
/** Initially open item ids when uncontrolled. */
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** Active category in the columns variant (controlled). */
category?: string;
onCategoryChange?: (category: string) => void;
/** Search text in the search variant (controlled). */
query?: string;
onQueryChange?: (query: string) => void;
/** A way to reach a person when the answer isn't here. Pass null to hide it. */
contact?: { label: string; description?: string; href?: string; onClick?: () => void } | null;
/** Draws the FAQ page-top illustration above the heading of the accordion and search layouts. The theme's own art is used when it has one. */
illustration?: boolean;
className?: string;
}
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
const { duration } = motionTokens;
export const faqExampleItems: FaqItem[] = [
{ question: "devs7udio는 무엇인가요?", answer: "devs7udio는 Motion과 Radix 위에 만든 React 컴포넌트와 블록 모음이에요. 소스를 프로젝트에 복사해 쓰기 때문에 모든 코드를 직접 고칠 수 있어요.", category: "일반" },
{ question: "우리 회사 디자인 시스템과 함께 쓸 수 있나요?", answer: "네. 컴포넌트가 surface, border, accent 같은 의미 토큰을 읽어서, 스타일시트 하나로 팔레트에 연결할 수 있어요.", category: "일반" },
{ question: "모션 줄이기 설정을 지원하나요?", answer: "모든 애니메이션에 모션 줄이기 분기가 있어요. 이동과 반복은 빼고 최종 상태와 포커스, 피드백은 그대로 유지해요.", category: "일반" },
{ question: "어떤 프레임워크에서 쓸 수 있나요?", answer: "React 19와 Next.js 16을 기준으로 해요. 컴포넌트는 클라이언트 컴포넌트라서 Vite와 React Router 앱에서도 돌아가요.", category: "기술" },
{ question: "컴포넌트는 어떻게 설치하나요?", answer: "컴포넌트 이름과 함께 CLI를 실행하거나 파일을 직접 복사해요. 필요한 패키지는 각 페이지에 적혀 있어요.", category: "기술" },
{ question: "업데이트는 어떻게 받나요?", answer: "새 컴포넌트와 수정 사항은 작은 단위로 나와요. CLI를 다시 실행하면 설치한 항목의 최신 버전을 받아요.", category: "기술" },
{ question: "고객사 프로젝트에도 쓸 수 있나요?", answer: "네. 무료 컴포넌트는 MIT 라이선스고, 상업 작업은 Pro 라이선스로 본인이나 팀이 쓸 수 있어요.", category: "라이선스" },
{ question: "요금제가 끝나면 어떻게 되나요?", answer: "이미 설치한 컴포넌트는 계속 쓸 수 있어요. 갱신은 새 릴리스와 업데이트를 받을 때만 필요해요.", category: "라이선스" },
];
const keyOf = (item: FaqItem) => item.id ?? item.question;
const normalize = (text: string) => text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
/** Wraps each match of `query` in a mark, keeping the original casing. */
function Highlight({ text, query }: { text: string; query: string }) {
const needle = normalize(query.trim());
if (!needle) return <>{text}</>;
const haystack = normalize(text);
const parts: ReactNode[] = [];
let from = 0, at = haystack.indexOf(needle);
while (at >= 0) {
if (at > from) parts.push(text.slice(from, at));
parts.push(<mark key={at} className={styles.mark}>{text.slice(at, at + needle.length)}</mark>);
from = at + needle.length;
at = haystack.indexOf(needle, from);
}
parts.push(text.slice(from));
return <>{parts}</>;
}
/** A short window of the answer around the first match, for closed items that match only in their answer. */
function snippet(answer: string, query: string) {
const at = normalize(answer).indexOf(normalize(query.trim()));
if (at < 0) return null;
const start = Math.max(0, answer.lastIndexOf(" ", Math.max(0, at - 36)) + 1);
const end = Math.min(answer.length, at + query.length + 60);
return `${start > 0 ? "…" : ""}${answer.slice(start, end).trim()}${end < answer.length ? "…" : ""}`;
}
const panelVariants: Variants = {
open: { height: "auto", opacity: 1, transition: { height: motionTokens.spring.smooth, opacity: { duration: duration.fast, ease: enter } } },
closed: { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: { duration: duration.instant, ease: standard } } },
};
const contentVariants: Variants = {
open: { y: 0, filter: "blur(0px)", transition: { y: motionTokens.spring.smooth, filter: { duration: duration.fast, ease: enter } } },
closed: { y: -6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: duration.instant, ease: standard } },
};
const reducedPanel: Variants = { open: { height: "auto", opacity: 1, transition: { duration: 0 } }, closed: { height: 0, opacity: 0, transition: { duration: 0 } } };
/** Arrow keys, Home, and End move between the questions of one list. */
function onListKeyDown(event: KeyboardEvent<HTMLElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const triggers = Array.from(event.currentTarget.querySelectorAll<HTMLElement>("[data-faq-trigger]"));
const index = triggers.indexOf(document.activeElement as HTMLElement);
if (index < 0) return;
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? triggers.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + triggers.length) % triggers.length;
triggers[next]!.focus();
}
function Question({ item, open, onToggle, query = "", reduced, baseId, layout }: { item: FaqItem; open: boolean; onToggle: () => void; query?: string; reduced: boolean; baseId: string; layout?: boolean }) {
const key = keyOf(item);
const safe = `${baseId}-${key.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const hint = query && !open && !normalize(item.question).includes(normalize(query.trim())) ? snippet(item.answer, query) : null;
return <motion.li className={styles.item} data-open={open ? "" : undefined}
layout={layout && !reduced ? "position" : false}
initial={layout ? (reduced ? { opacity: 0 } : { opacity: 0, height: 0 }) : false}
animate={{ opacity: 1, height: "auto" }}
exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, height: 0, transition: { height: motionTokens.spring.smooth, opacity: { duration: duration.instant } } }}
transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: duration.fast }, layout: motionTokens.spring.smooth }}>
<h3 className={styles.heading}>
<button type="button" id={`${safe}-q`} className={styles.trigger} data-faq-trigger="" aria-expanded={open} aria-controls={`${safe}-a`} onClick={onToggle}>
<span className={styles.question}><Highlight text={item.question} query={query} /></span>
<UiIcon iconRole="plus" className={styles.icon} size={18} strokeWidth={1.75} aria-hidden="true" />
</button>
</h3>
<AnimatePresence initial={false}>
{hint && <motion.p key="hint" className={styles.hint} initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: duration.fast } }}>
<Highlight text={hint} query={query} />
</motion.p>}
</AnimatePresence>
<AnimatePresence initial={false}>
{open && <motion.div key="answer" id={`${safe}-a`} role="region" aria-labelledby={`${safe}-q`} className={styles.panel} variants={reduced ? reducedPanel : panelVariants} initial="closed" animate="open" exit="closed">
<motion.p className={styles.answer} variants={reduced ? undefined : contentVariants}><Highlight text={item.answer} query={query} /></motion.p>
</motion.div>}
</AnimatePresence>
</motion.li>;
}
function Contact({ contact }: { contact: NonNullable<FaqSectionProps["contact"]> }) {
const inner = <><span className={styles.contactText}><span>{contact.label}</span>{contact.description && <span>{contact.description}</span>}</span><UiIcon iconRole="arrow-right" className={styles.contactArrow} size={16} strokeWidth={1.75} aria-hidden="true" /></>;
return contact.href
? <a className={styles.contact} href={contact.href} onClick={() => { playUiSound("tick"); contact.onClick?.(); }}>{inner}</a>
: <button type="button" className={styles.contact} onClick={() => { playUiSound("click"); contact.onClick?.(); }}>{inner}</button>;
}
/**
* A frequently asked questions section in three layouts: one centered accordion, a category rail beside its questions,
* and a searchable list that filters and highlights as you type. Answers open on a height spring with the icon turning
* to a close mark; arrow keys move between questions.
*/
export const FaqSection = forwardRef<HTMLElement, FaqSectionProps>(function FaqSection({
variant = "accordion",
title = "자주 묻는 질문",
description = "첫 컴포넌트를 설치하기 전에 알아 두면 좋은 내용을 모았어요.",
items = faqExampleItems,
multiple = false,
value,
defaultValue,
onValueChange,
category: categoryProp,
onCategoryChange,
query: queryProp,
onQueryChange,
contact = { label: "궁금한 점이 더 있나요?", description: "하루 안에 팀이 답해 드려요." },
illustration = false,
className,
}, ref) {
const id = useId();
const reduced = !!useReducedMotion();
const [innerOpen, setInnerOpen] = useState<string[]>(defaultValue ?? (items[0] ? [keyOf(items[0])] : []));
const open = value ?? innerOpen;
const categories = useMemo(() => Array.from(new Set(items.map(item => item.category ?? "일반"))), [items]);
const [innerCategory, setInnerCategory] = useState(categories[0] ?? "일반");
const activeCategory = categoryProp ?? innerCategory;
const [direction, setDirection] = useState(0);
const [innerQuery, setInnerQuery] = useState("");
const query = queryProp ?? innerQuery;
const setOpen = (next: string[]) => { if (value === undefined) setInnerOpen(next); onValueChange?.(next); };
const toggle = (key: string) => { playUiSound("tick"); setOpen(open.includes(key) ? open.filter(entry => entry !== key) : multiple ? [...open, key] : [key]); };
const chooseCategory = (next: string) => {
playUiSound("tick");
if (next === activeCategory) return;
setDirection(Math.sign(categories.indexOf(next) - categories.indexOf(activeCategory)));
if (categoryProp === undefined) setInnerCategory(next);
onCategoryChange?.(next);
};
const setQuery = (next: string) => { if (queryProp === undefined) setInnerQuery(next); onQueryChange?.(next); };
const needle = normalize(query.trim());
const results = variant === "search" && needle ? items.filter(item => normalize(`${item.question} ${item.answer}`).includes(needle)) : items;
const art = illustration && <Illustration name="page-faq" animated className={styles.art} />;
const heading = <div className={styles.intro}>
<h2 id={`${id}-title`} className={styles.title}>{title}</h2>
{description && <p className={styles.description}>{description}</p>}
</div>;
const list = (entries: FaqItem[], layout = false) => <ul className={styles.list} onKeyDown={onListKeyDown}>
<AnimatePresence initial={false}>
{entries.map(item => <Question key={keyOf(item)} item={item} open={open.includes(keyOf(item))} onToggle={() => toggle(keyOf(item))} query={variant === "search" ? query : ""} reduced={reduced} baseId={id} layout={layout} />)}
</AnimatePresence>
</ul>;
return <section ref={ref} className={[styles.faq, styles[variant], className].filter(Boolean).join(" ")} aria-labelledby={`${id}-title`}>
{variant === "accordion" && <div className={styles.stack}>
{art}
{heading}
{list(items)}
{contact && <Contact contact={contact} />}
</div>}
{variant === "columns" && <div className={styles.columnsGrid}>
<div className={styles.rail}>
{heading}
<LayoutGroup id={`${id}-rail`}>
<div className={styles.categories} role="group" aria-label="질문 주제">
{categories.map(entry => {
const count = items.filter(item => (item.category ?? "일반") === entry).length;
return <button key={entry} type="button" className={styles.category} aria-pressed={entry === activeCategory} onClick={() => chooseCategory(entry)}>
{entry === activeCategory && <motion.span layoutId="category" className={styles.categoryHighlight} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden="true" />}
<span>{entry}</span><span className={styles.count}>{count}</span>
</button>;
})}
</div>
</LayoutGroup>
{contact && <Contact contact={contact} />}
</div>
<div className={styles.stage}>
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.div key={activeCategory} className={styles.stageFace} custom={direction}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: direction * 16, filter: `blur(${motionTokens.blur.subtle}px)` }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: direction * -12, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: duration.instant, ease: standard } }}
transition={reduced ? { duration: 0 } : { y: motionTokens.spring.smooth, opacity: { duration: duration.fast, ease: enter }, filter: { duration: duration.fast, ease: enter } }}>
{list(items.filter(item => (item.category ?? "일반") === activeCategory))}
</motion.div>
</AnimatePresence>
</div>
</div>}
{variant === "search" && <div className={styles.stack}>
{art}
{heading}
<div className={styles.searchRow}>
<SearchField label="질문 검색" placeholder="설치, 라이선스, 모션으로 찾아보세요" value={query} onValueChange={setQuery} />
<p className={styles.resultCount} aria-live="polite">{needle ? `답변 ${results.length}개` : `질문 ${items.length}개`}</p>
</div>
{list(results, true)}
<AnimatePresence initial={false}>
{needle && results.length === 0 && <motion.div key="empty" className={styles.empty}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: duration.instant } }}
transition={{ duration: reduced ? 0 : duration.standard, ease: enter }}>
<p>“{query.trim()}”에 맞는 답변이 없어요.</p>
<button type="button" className={styles.clear} onClick={() => { playUiSound("click"); setQuery(""); }}>검색어 지우기</button>
</motion.div>}
</AnimatePresence>
{contact && <Contact contact={contact} />}
</div>}
</section>;
});
FaqSection.displayName = "FaqSection";
const variantOptions = [{ value: "accordion", label: "아코디언" }, { value: "columns", label: "두 단 구성" }, { value: "search", label: "검색형" }];
/** Preview: the FAQ with a switch between its three layouts. */
export function FaqSectionBlock({ variant: initial = "accordion" }: { variant?: FaqSectionVariant }) {
const [variant, setVariant] = useState<FaqSectionVariant>(initial);
const [asked, setAsked] = useState(false);
return <div className={styles.preview}>
<SegmentedControl label="FAQ 레이아웃" options={variantOptions} value={variant} onValueChange={next => setVariant(next as FaqSectionVariant)} />
<div className={styles.frame}>
<FaqSection key={variant} variant={variant} illustration contact={{ label: asked ? "문의를 보냈어요" : "궁금한 점이 더 있나요?", description: asked ? "담당자 김하나 님이 하루 안에 답해 드려요." : "하루 안에 팀이 답해 드려요.", onClick: () => setAsked(true) }} />
</div>
</div>;
}
export default FaqSectionBlock;
Used items
- Search fieldComponent
A text input for search queries with a clear action.
- Segmented controlComponent
A row of mutually exclusive options with a highlight that moves to the chosen segment.
- IllustrationComponent
Flat shape illustrations for heroes, section headers and card covers. A theme without its own art draws the base art in its colours.