# FAQ section

> FAQs as an accordion, a topic rail, or a searchable list that highlights matches.

A shadcn/ui block from the [@silicon-ui](https://ui.teamofsilicons.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/silicon-ui/faq-section).

- Page, with a live preview: https://shadcn.exchange/r/silicon-ui/faq-section
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/silicon-ui/faq-section-light-1503fd21a31e10c2.png
- Registry item: https://ui.teamofsilicons.com/r/faq-section.json
- Categories: blocks
- Listed: 2026-10-17, source last changed 2026-10-17

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/silicon-ui/faq-section.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `https://ui.teamofsilicons.com/r/silicon-motion-tokens.json`, `https://ui.teamofsilicons.com/r/search-field.json`, `https://ui.teamofsilicons.com/r/segmented-control.json`

## Notes from the registry

Docs and live preview: https://ui.teamofsilicons.com/faq-section

## Files

### `registry/blocks/faq-section/faq-section.tsx`

```tsx
"use client";

import { forwardRef, useId, useMemo, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import type { Variants } from "motion/react";
import { ArrowRight, Plus } from "lucide-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";

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;
  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: "What is Silicon UI?", answer: "Silicon UI is a free, open source library of React components and blocks built on Motion and Radix. You copy the source into your project, so every line is yours to change.", category: "General" },
  { question: "Does it work with my design system?", answer: "Yes. Components read semantic tokens such as surface, border, and accent, so mapping them to your palette takes one stylesheet.", category: "General" },
  { question: "Is reduced motion supported?", answer: "Every animation has a reduced motion branch. It keeps the final state, focus, and feedback while removing travel and looping.", category: "General" },
  { question: "Which frameworks are supported?", answer: "Silicon UI targets React 19 and Next.js 16. The components are client components and also run in Vite and React Router apps.", category: "Technical" },
  { question: "How do I install a component?", answer: "Run the shadcn CLI with the component name, or copy the files by hand. Each page lists the packages it needs.", category: "Technical" },
  { question: "How do updates work?", answer: "New components and fixes ship in small releases. Run the CLI again to pull the latest version of anything you installed.", category: "Technical" },
  { question: "Can I use Silicon UI in client projects?", answer: "Yes. Every component and block is MIT licensed, free for personal and commercial work.", category: "Licensing" },
  { question: "Do I need to credit Silicon UI?", answer: "Keep the MIT license notice in the files you copy. A link back is welcome but never required.", category: "Licensing" },
];

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>
        <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><ArrowRight className={styles.contactArrow} size={16} strokeWidth={1.75} aria-hidden="true" /></>;
  return contact.href
    ? <a className={styles.contact} href={contact.href} onClick={contact.onClick}>{inner}</a>
    : <button type="button" className={styles.contact} onClick={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 = "Frequently asked questions",
  description = "Everything you need to know before you install your first component.",
  items = faqExampleItems,
  multiple = false,
  value,
  defaultValue,
  onValueChange,
  category: categoryProp,
  onCategoryChange,
  query: queryProp,
  onQueryChange,
  contact = { label: "Still have a question?", description: "Our team replies within a day." },
  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 ?? "General"))), [items]);
  const [innerCategory, setInnerCategory] = useState(categories[0] ?? "General");
  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) => setOpen(open.includes(key) ? open.filter(entry => entry !== key) : multiple ? [...open, key] : [key]);
  const chooseCategory = (next: string) => {
    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 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}>
      {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="Question topics">
            {categories.map(entry => {
              const count = items.filter(item => (item.category ?? "General") === 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 ?? "General") === activeCategory))}
          </motion.div>
        </AnimatePresence>
      </div>
    </div>}

    {variant === "search" && <div className={styles.stack}>
      {heading}
      <div className={styles.searchRow}>
        <SearchField label="Search questions" placeholder="Try install, license, or motion" value={query} onValueChange={setQuery} />
        <p className={styles.resultCount} aria-live="polite">{needle ? `${results.length} ${results.length === 1 ? "answer" : "answers"}` : `${items.length} questions`}</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>No answers mention “{query.trim()}”.</p>
          <button type="button" className={styles.clear} onClick={() => setQuery("")}>Clear search</button>
        </motion.div>}
      </AnimatePresence>
      {contact && <Contact contact={contact} />}
    </div>}
  </section>;
});

FaqSection.displayName = "FaqSection";

const variantOptions = [{ value: "accordion", label: "Accordion" }, { value: "columns", label: "Two column" }, { value: "search", label: "Searchable" }];

/** 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 layout" options={variantOptions} value={variant} onValueChange={next => setVariant(next as FaqSectionVariant)} />
    <div className={styles.frame}>
      <FaqSection key={variant} variant={variant} contact={{ label: asked ? "Message sent to support" : "Still have a question?", description: asked ? "Hannah will reply within a day." : "Our team replies within a day.", onClick: () => setAsked(true) }} />
    </div>
  </div>;
}

export default FaqSectionBlock;
```

### `registry/blocks/faq-section/faq-section.module.css`

```css
.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-block); max-width: 720px; margin: 0 auto; padding: var(--space-screen) var(--space-section); }
.intro { display: grid; gap: var(--space-group); }
.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-inset); border: 0; padding: var(--space-inset) var(--space-tight); 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-page) var(--space-comfort) var(--space-tight); 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-page) var(--space-inset) var(--space-tight); color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); }
.mark { border-radius: var(--radius-detail); padding: 0 1px; background: var(--accent-subtle); color: inherit; }

.contact { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--space-inset); border: 1px solid var(--border); border-radius: var(--radius-control); padding: var(--space-inset) var(--space-comfort); 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: var(--space-hair); 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-stage); margin: 0 auto; padding: var(--space-screen) var(--space-block); }
.rail { position: sticky; top: var(--space-section); display: grid; gap: var(--space-section); }
.categories { display: grid; gap: var(--space-hair); }
.category { position: relative; isolation: isolate; display: flex; height: 40px; align-items: center; justify-content: space-between; gap: var(--space-group); border: 0; border-radius: var(--radius-control); padding: 0 var(--space-group); 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; corner-shape: 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-related); }
.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-group); padding: var(--space-section) 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-inset); 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-block); padding-inline: var(--space-section); }
  .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-page) var(--space-inset); }
  .answer, .hint { padding-right: var(--space-related); }
}

.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-inset); }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--background); }

@media (prefers-reduced-motion: reduce) {
  .icon, .contact, .contactArrow, .category, .clear { transition: none; }
  .contact:active, .clear:active { transform: none; }
}
```

### `registry/components/segmented-control/segmented-control.tsx`

```tsx
"use client";

import { useEffect, useId, useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./segmented-control.module.css";

export interface Segment { value: string; label: string; /** Optional content after the label, such as a badge. */ accessory?: ReactNode; }
export interface SegmentedControlProps {
  options: Segment[];
  value: string;
  onValueChange: (value: string) => void;
  label?: string;
  /** Called when the pointer or focus reaches an option, before it is chosen. Use it to start loading what that option shows. */
  onOptionIntent?: (value: string) => void;
  /** `sm` (default) or `xs`, a compact 24px control for dense UI such as an Edit/Read toggle in a toolbar. */
  size?: "sm" | "xs";
  className?: string;
}

export default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, size = "sm", className }: SegmentedControlProps) {
  const id = useId();
  const reduced = useReducedMotion();
  const track = useRef<HTMLDivElement>(null);

  // When the options are wider than the container, the track scrolls inside itself. Edges fade only on the side with more to see.
  useLayoutEffect(() => {
    const node = track.current;
    if (!node) return;
    const edges = () => {
      const rest = node.scrollWidth - node.clientWidth - node.scrollLeft;
      node.toggleAttribute("data-fade-start", node.scrollLeft > 1);
      node.toggleAttribute("data-fade-end", rest > 1);
    };
    edges();
    node.addEventListener("scroll", edges, { passive: true });
    const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(edges);
    observer?.observe(node);
    return () => { node.removeEventListener("scroll", edges); observer?.disconnect(); };
  }, [options.length]);

  // The selected option is always scrolled fully into view, with a little room so it clears the fade.
  const first = useRef(true);
  useEffect(() => {
    const node = track.current;
    const button = node?.querySelector<HTMLElement>('[aria-pressed="true"]');
    if (!node || !button || node.scrollWidth <= node.clientWidth) { first.current = false; return; }
    const room = 20, start = button.offsetLeft - room, end = button.offsetLeft + button.offsetWidth + room - node.clientWidth;
    const left = node.scrollLeft > start ? start : node.scrollLeft < end ? end : node.scrollLeft;
    if (left !== node.scrollLeft) node.scrollTo({ left: Math.max(0, left), behavior: first.current || reduced ? "auto" : "smooth" });
    first.current = false;
  }, [value, reduced]);

  // Arrow keys, Home and End move the selection like a tab list; only the selected option is a tab stop.
  const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));
  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    const last = options.length - 1;
    const target = event.key === "ArrowRight" || event.key === "ArrowDown" ? (selectedIndex === last ? 0 : selectedIndex + 1)
      : event.key === "ArrowLeft" || event.key === "ArrowUp" ? (selectedIndex === 0 ? last : selectedIndex - 1)
        : event.key === "Home" ? 0 : event.key === "End" ? last : -1;
    if (target < 0 || !options[target]) return;
    event.preventDefault();
    onValueChange(options[target].value);
    track.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(options[target].value)}"]`)?.focus({ preventScroll: true });
  };

  return <div className={`${styles.root} ${className ?? ""}`} role="group" aria-label={label} data-size={size}>
    <LayoutGroup id={id}><motion.div ref={track} layoutScroll className={styles.track}>
      {options.map((option, index) => <button key={option.value} id={`${id}-${option.value}`} className={styles.button} type="button" data-value={option.value} aria-pressed={value === option.value} tabIndex={index === selectedIndex ? 0 : -1} onClick={() => onValueChange(option.value)} onKeyDown={onKeyDown} onPointerEnter={onOptionIntent ? () => onOptionIntent(option.value) : undefined} onFocus={onOptionIntent ? () => onOptionIntent(option.value) : undefined}>
        {value === option.value && <motion.span className={styles.selection} layoutId="selection" layoutDependency={value} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden="true" />}
        <span className={styles.label}>{option.label}{option.accessory}</span>
      </button>)}
    </motion.div></LayoutGroup>
  </div>;
}
```

### `registry/components/segmented-control/segmented-control.module.css`

```css
/* The frame can shrink below its content inside flex and grid parents; the track then scrolls inside it. */
.root { display: inline-flex; min-width: 0; max-width: 100%; flex-shrink: 1; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); }
/* The track owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */
.track { --fade-start: 0px; --fade-end: 0px; isolation: isolate; display: flex; min-width: 0; gap: var(--space-hair); overflow-x: auto; overscroll-behavior-x: contain; padding: var(--space-tight); border-radius: inherit; corner-shape: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); }
.track::-webkit-scrollbar { display: none; }
.track[data-fade-start] { --fade-start: 20px; }
.track[data-fade-end] { --fade-end: 20px; }
/* Items never scale or change weight; only the highlight travels and the label color follows it. */
.button { position: relative; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - var(--space-tight)); padding: 0 var(--space-group); background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .button:hover { color: var(--foreground); } }
.button:active, .button[aria-pressed="true"] { color: var(--foreground); }
.selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; corner-shape: inherit; background: var(--surface); box-shadow: var(--shadow-resting); }
.label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: var(--space-related); }
/* Compact: 24px segments in a thinner track, at the small type step. */
.root[data-size="xs"] .track { padding: var(--space-hair); }
.root[data-size="xs"] .button { min-height: var(--control-height-xs); padding: 0 var(--space-related); font-size: var(--text-xs); }
@media (max-width: 380px) { .button { padding: 0 var(--space-group); } }
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }
```

### `registry/components/search-field/search-field.tsx`

```tsx
"use client";
import { forwardRef, useId, useRef } from "react";
import type { InputHTMLAttributes } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Search, X as Xmark } from "lucide-react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./search-field.module.css";
export interface SearchFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> { label: string; value: string; onValueChange: (value: string) => void }
export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField({ label, value, onValueChange, id, className, ...props }, ref) {
  const generated = useId(); const controlId = id ?? generated; const reduced = useReducedMotion(); const inputRef = useRef<HTMLInputElement | null>(null);
  const setRefs = (node: HTMLInputElement | null) => { inputRef.current = node; if (typeof ref === "function") ref(node); else if (ref) ref.current = node; };
  // Clearing returns focus to the field, since the clear button unmounts under the pointer.
  function clear() { onValueChange(""); inputRef.current?.focus(); }
  return <div className={styles.field}><label htmlFor={controlId}>{label}</label><div className={styles.shell} data-filled={value ? "true" : undefined}>
    <Search width={18} height={18} aria-hidden="true"/><input {...props} ref={setRefs} id={controlId} type="search" value={value} onChange={event => onValueChange(event.target.value)} className={[styles.input, className].filter(Boolean).join(" ")} />
    {/* The clear button has a reserved slot, so the field never changes width when it appears or leaves. */}
    <span className={styles.clearSlot}><AnimatePresence initial={false}>{value ? <motion.button key="clear" type="button" tabIndex={0} onClick={clear} aria-label="Clear search" initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .8, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .8, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }} whileTap={reduced ? undefined : { scale: .96, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }} transition={reduced ? { duration: motionTokens.duration.instant } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } }}><Xmark width={16} height={16} aria-hidden="true" /></motion.button> : null}</AnimatePresence></span>
  </div></div>;
});
```

### `registry/components/search-field/search-field.module.css`

```css
.field { display: grid; gap: var(--space-related); min-width: 0; }
.field label { font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); }
/* Focus darkens the border; the shell never changes size. */
.shell { display: flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-related); border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 var(--space-group); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .shell:hover:not(:focus-within) { border-color: var(--foreground); } }
.shell:focus-within { border-color: var(--foreground); }
/* The glass wakes up with the field: muted at rest, full contrast while searching. */
.shell > svg { flex: 0 0 auto; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }
.shell:focus-within > svg, .shell[data-filled="true"] > svg { color: var(--foreground); }
.input { width: 100%; min-width: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); outline: 0; }
.input:focus-visible { outline: 0; }
.input::placeholder { color: var(--text-muted); }
.input::-webkit-search-cancel-button { display: none; }
.clearSlot { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; }
.shell button { display: grid; width: 24px; height: 24px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-muted); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .shell button:hover { background: var(--surface-muted); color: var(--foreground); } }
@media (prefers-reduced-motion: reduce) { .shell, .shell > svg, .shell button { transition: none; } }
```
