{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"clarifying-question","type":"registry:component","title":"Clarifying question","description":"The agent pauses to ask. Pick with a click or a number key, or type your own; it folds into a one-line receipt.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/clarifying-question.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUp } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * CLARIFYING QUESTION: the agent pauses to ask\n *\n *   asking    the question and its options; press 1–9 or click\n *   other     \"Something else\" turns into a field in place\n *   multiple  options toggle (their checks draw themselves), then\n *             \"Continue · 3\", the count rolling as you pick\n *   answered  the same card folds into one line: the options close,\n *             \"Needs your answer\" lifts away and the question and\n *             your answer slide into its place as a check draws\n *   skipped   \"Skipped, the agent will decide\"\n *\n * Pair it with a client-side tool that has no execute: render it\n * for the tool's input and send the answer back as the output.\n * ───────────────────────────────────────────────────────── */\n\nexport interface QuestionOption {\n  id: string;\n  label: string;\n  description?: string;\n}\n\nexport interface QuestionAnswer {\n  ids: string[];\n  labels: string[];\n  /** What they typed under \"Something else\". */\n  other?: string;\n}\n\nexport interface ClarifyingQuestionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSubmit\"> {\n  question: string;\n  detail?: string;\n  options: QuestionOption[];\n  /** Allow several answers, confirmed with Continue. */\n  multiple?: boolean;\n  /** Offer \"Something else\" with a field. */\n  allowOther?: boolean;\n  otherLabel?: string;\n  /** The answer once given, e.g. the tool's output; shows the folded line. */\n  answer?: QuestionAnswer | null;\n  onAnswer: (answer: QuestionAnswer) => void;\n  /** Shows Skip; the agent continues with its own choice. */\n  onSkip?: () => void;\n  skipped?: boolean;\n  skippedText?: string;\n  autoFocus?: boolean;\n}\n\nconst OTHER = \"__other__\";\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, className = \"\", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`grid ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 460ms ${MORPH}, opacity ${show ? \"320ms\" : \"160ms\"} ${MORPH}, filter 320ms ${MORPH}`,\n      }}\n    >\n      <span className=\"min-w-0 truncate [clip-path:inset(-4px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\nfunction DrawnCheck({ drawn, reduced, className = \"size-3\" }: { drawn: boolean; reduced: boolean; className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className={className}>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 320ms ${MORPH} 60ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\nexport function ClarifyingQuestion({\n  question,\n  detail,\n  options,\n  multiple = false,\n  allowOther = true,\n  otherLabel = \"Something else\",\n  answer,\n  onAnswer,\n  onSkip,\n  skipped = false,\n  skippedText = \"the agent will decide\",\n  autoFocus = false,\n  className = \"\",\n  ...props\n}: ClarifyingQuestionProps) {\n  const id = React.useId();\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState<QuestionAnswer | null>(null);\n  const [ownSkipped, setOwnSkipped] = React.useState(false);\n  const [picked, setPicked] = React.useState<string[]>([]);\n  const [otherOpen, setOtherOpen] = React.useState(false);\n  const [otherText, setOtherText] = React.useState(\"\");\n  const [active, setActive] = React.useState(0);\n  const [highlight, setHighlight] = React.useState({ top: 0, height: 0, ready: false });\n\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLButtonElement | HTMLDivElement | null)[]>([]);\n  const otherRef = React.useRef<HTMLInputElement>(null);\n\n  const final = answer ?? own;\n  const isSkipped = skipped || ownSkipped;\n  const done = Boolean(final) || isSkipped;\n  const rows = allowOther ? [...options, { id: OTHER, label: otherLabel }] : options;\n\n  // The options are about to fold away: keep focus on the card instead of dropping it on the page.\n  const fold = () => {\n    if (surfaceRef.current?.contains(document.activeElement)) surfaceRef.current.focus({ preventScroll: true });\n  };\n\n  React.useEffect(() => {\n    if (autoFocus && !done) (rowRefs.current[0] as HTMLElement | null)?.focus({ preventScroll: true });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  React.useEffect(() => {\n    if (otherOpen) otherRef.current?.focus({ preventScroll: true });\n  }, [otherOpen]);\n\n  // Digits also answer when nothing else has focus (never while typing in a field).\n  const chooseRef = React.useRef<(i: number) => void>(() => {});\n  React.useEffect(() => {\n    if (done) return;\n    const onKey = (e: KeyboardEvent) => {\n      const focused = document.activeElement;\n      if ((focused && focused !== document.body) || e.metaKey || e.ctrlKey || e.altKey || !/^[1-9]$/.test(e.key)) return;\n      if (Number(e.key) > rows.length) return;\n      e.preventDefault();\n      chooseRef.current(Number(e.key) - 1);\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [done, rows.length]);\n\n  // The highlight slides to the active row and takes its height.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    if (!row || done) return;\n    const top = row.offsetTop;\n    const height = row.offsetHeight;\n    setHighlight((h) => (h.top === top && h.height === height && h.ready ? h : { top, height, ready: h.height > 0 }));\n  }, [active, otherOpen, done, rows.length]);\n\n  const submit = (picks: string[], other?: string) => {\n    const order = (x: string) => options.findIndex((o) => o.id === x);\n    const ids = [...picks].sort((a, b) => order(a) - order(b));\n    const next: QuestionAnswer = {\n      ids,\n      labels: ids.map((i) => options.find((o) => o.id === i)?.label ?? i),\n      ...(other ? { other } : {}),\n    };\n    fold();\n    if (answer === undefined) setOwn(next);\n    onAnswer(next);\n  };\n\n  const skip = () => {\n    fold();\n    setOwnSkipped(true);\n    onSkip?.();\n  };\n\n  const choose = (i: number) => {\n    const row = rows[i];\n    if (!row) return;\n    setActive(i);\n    if (row.id === OTHER) {\n      setOtherOpen((o) => (multiple ? !o : true));\n      return;\n    }\n    if (multiple) setPicked((p) => (p.includes(row.id) ? p.filter((x) => x !== row.id) : [...p, row.id]));\n    else submit([row.id]);\n  };\n\n  React.useLayoutEffect(() => {\n    chooseRef.current = choose;\n  });\n\n  const submitOther = () => {\n    const text = otherText.trim();\n    if (!text) return;\n    if (multiple) submit(picked, text);\n    else submit([], text);\n  };\n\n  const onListKey = (e: React.KeyboardEvent) => {\n    if (e.target instanceof HTMLInputElement) return;\n    if (/^[1-9]$/.test(e.key) && Number(e.key) <= rows.length) {\n      e.preventDefault();\n      choose(Number(e.key) - 1);\n      (rowRefs.current[Number(e.key) - 1] as HTMLElement | null)?.focus();\n    } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      const next = (active + (e.key === \"ArrowDown\" ? 1 : -1) + rows.length) % rows.length;\n      setActive(next);\n      (rowRefs.current[next] as HTMLElement | null)?.focus();\n    }\n  };\n\n  const summary = final ? [...final.labels, final.other].filter(Boolean).join(\", \") : \"\";\n  const receipt = isSkipped && !final ? `Skipped, ${skippedText}` : summary;\n  const count = picked.length + (otherOpen && otherText.trim() ? 1 : 0);\n  const canContinue = count > 0;\n\n  return (\n    <div ref={surfaceRef} tabIndex={-1} className={`overflow-hidden rounded-[20px] border border-border bg-background outline-none ${className}`} {...props}>\n      {/* The header stays put; once answered it becomes the whole card: question · answer. */}\n      <div\n        className=\"flex min-w-0 items-center text-[12px]\"\n        style={{ padding: done ? \"13px 16px\" : \"14px 16px 0\", transition: reduced ? \"none\" : `padding 460ms ${MORPH}` }}\n      >\n        <span aria-hidden=\"true\" className=\"relative mr-2 flex size-4 shrink-0 items-center justify-center\">\n          <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(!done, reduced)}>\n            <span className=\"relative flex size-1.5\">\n              <span className={`absolute inset-0 rounded-full bg-primary motion-reduce:animate-none ${done ? \"\" : \"animate-[ui-ping_1.6s_cubic-bezier(0,0,0.2,1)_infinite]\"}`} />\n              <span className=\"relative size-1.5 rounded-full bg-primary\" />\n            </span>\n          </span>\n          <span className=\"absolute inset-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground\" style={swap(done, reduced)}>\n            <DrawnCheck drawn={done} reduced={reduced} className=\"size-2.5\" />\n          </span>\n        </span>\n        <span aria-hidden={done || undefined} className=\"shrink-0 text-muted-foreground\">\n          <TextMorph>{done ? \"\" : \"Needs your answer\"}</TextMorph>\n        </span>\n        <Reveal show={done} reduced={reduced} className=\"min-w-0\">\n          <span title={question} className=\"text-muted-foreground\">\n            {question}\n          </span>\n        </Reveal>\n        <Reveal show={done} reduced={reduced} className=\"max-w-[55%] shrink-0\">\n          <span aria-hidden=\"true\" className=\"px-2 text-muted-foreground/50\">\n            ·\n          </span>\n          <span title={receipt} className=\"font-medium text-foreground\">\n            {receipt}\n          </span>\n        </Reveal>\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {done ? `${question}: ${receipt}` : \"\"}\n      </span>\n\n      {/* Everything else folds shut once it's answered. */}\n      <div\n        inert={done}\n        aria-hidden={done || undefined}\n        className=\"grid\"\n        style={{\n          gridTemplateRows: done ? \"0fr\" : \"1fr\",\n          opacity: done ? 0 : 1,\n          transition: reduced ? \"none\" : `grid-template-rows 460ms ${MORPH}, opacity ${done ? \"180ms\" : \"320ms\"} ${MORPH}`,\n        }}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <div className=\"p-1.5 pt-0\">\n            <div className=\"px-2.5 pb-2.5\">\n              <p id={`${id}-q`} className=\"mt-1.5 text-[14px] font-medium leading-snug text-foreground\">\n                {question}\n              </p>\n              {detail && <p className=\"mt-0.5 text-[12.5px] leading-relaxed text-muted-foreground\">{detail}</p>}\n            </div>\n\n            <div role={multiple ? \"group\" : \"radiogroup\"} aria-labelledby={`${id}-q`} onKeyDown={onListKey} className=\"relative\">\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute inset-x-0 top-0 rounded-[14px] bg-accent\"\n                style={{\n                  height: highlight.height,\n                  transform: `translateY(${highlight.top}px)`,\n                  transition: highlight.ready && !reduced ? `transform 200ms ${EASE}, height 200ms ${EASE}` : \"none\",\n                }}\n              />\n              {rows.map((row, i) => {\n                const isOther = row.id === OTHER;\n                const checked = isOther ? otherOpen : picked.includes(row.id);\n                const description = \"description\" in row ? row.description : undefined;\n                const marker = multiple ? (\n                  <span\n                    aria-hidden=\"true\"\n                    className={`flex size-[18px] shrink-0 items-center justify-center rounded-[6px] transition-[background-color,box-shadow] duration-200 ${\n                      checked ? \"bg-primary text-primary-foreground\" : \"shadow-[inset_0_0_0_1.5px_var(--border)]\"\n                    }`}\n                  >\n                    <DrawnCheck drawn={checked} reduced={reduced} />\n                  </span>\n                ) : (\n                  <kbd\n                    aria-hidden=\"true\"\n                    className={`flex size-[18px] shrink-0 items-center justify-center rounded-[6px] font-mono text-[10.5px] transition-colors ${\n                      i === active ? \"bg-background text-foreground shadow-[0_0_0_1px_var(--border)]\" : \"text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n                    }`}\n                  >\n                    {i + 1}\n                  </kbd>\n                );\n\n                if (isOther && otherOpen) {\n                  return (\n                    <div\n                      key={row.id}\n                      ref={(el) => {\n                        rowRefs.current[i] = el;\n                      }}\n                      className=\"relative flex min-h-11 items-center gap-3 rounded-[14px] px-2.5\"\n                    >\n                      {marker}\n                      <input\n                        ref={otherRef}\n                        value={otherText}\n                        onChange={(e) => setOtherText(e.target.value)}\n                        onFocus={() => setActive(i)}\n                        onKeyDown={(e) => {\n                          if (e.nativeEvent.isComposing) return;\n                          if (e.key === \"Enter\") {\n                            e.preventDefault();\n                            submitOther();\n                          } else if (e.key === \"Escape\") {\n                            e.preventDefault();\n                            setOtherOpen(false);\n                            requestAnimationFrame(() => (rowRefs.current[i] as HTMLElement | null)?.focus());\n                          }\n                        }}\n                        placeholder=\"Type your answer\"\n                        aria-label={otherLabel}\n                        className=\"h-8 min-w-0 flex-1 bg-transparent text-[13.5px] text-foreground outline-none placeholder:text-muted-foreground animate-[ui-fade-in_200ms_ease-out_both] motion-reduce:animate-none\"\n                      />\n                      {!multiple && (\n                        <button\n                          type=\"button\"\n                          aria-label=\"Send answer\"\n                          disabled={!otherText.trim()}\n                          onClick={submitOther}\n                          className={`flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[opacity,transform] active:scale-[0.94] disabled:opacity-30 ${FOCUS}`}\n                        >\n                          <ArrowUp className=\"size-3.5\" strokeWidth={2.4} />\n                        </button>\n                      )}\n                    </div>\n                  );\n                }\n\n                return (\n                  <button\n                    key={row.id}\n                    ref={(el) => {\n                      rowRefs.current[i] = el;\n                    }}\n                    type=\"button\"\n                    role={multiple ? \"checkbox\" : \"radio\"}\n                    aria-checked={multiple ? checked : false}\n                    tabIndex={i === active ? 0 : -1}\n                    onClick={() => choose(i)}\n                    onPointerMove={() => i !== active && setActive(i)}\n                    onFocus={() => setActive(i)}\n                    className=\"relative flex min-h-11 w-full items-center gap-3 rounded-[14px] px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/30\"\n                  >\n                    {marker}\n                    <span className=\"min-w-0 flex-1\">\n                      <span className={`block text-[13.5px] ${isOther ? \"text-muted-foreground\" : \"text-foreground\"}`}>{isOther ? `${row.label}…` : row.label}</span>\n                      {description && <span className=\"mt-px block text-[12px] leading-snug text-muted-foreground\">{description}</span>}\n                    </span>\n                  </button>\n                );\n              })}\n            </div>\n\n            {(onSkip || multiple) && (\n              <div className=\"flex items-center justify-between gap-2 px-1 pb-0.5 pt-1.5\">\n                {onSkip ? (\n                  <button\n                    type=\"button\"\n                    onClick={skip}\n                    className={`h-8 rounded-full px-2.5 text-[12.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n                  >\n                    Skip\n                  </button>\n                ) : (\n                  <span />\n                )}\n                {multiple && (\n                  <button\n                    type=\"button\"\n                    disabled={!canContinue}\n                    onClick={() => submit(picked, otherOpen && otherText.trim() ? otherText.trim() : undefined)}\n                    className={`inline-flex h-8 items-center rounded-full bg-primary px-3.5 text-[12.5px] font-medium text-primary-foreground transition-[opacity,transform] duration-300 active:scale-[0.96] disabled:opacity-35 ${FOCUS}`}\n                  >\n                    Continue\n                    {/* \" · 3\" opens beside it with the first pick and rolls as you pick more. */}\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"grid\"\n                      style={{\n                        gridTemplateColumns: count > 0 ? \"1fr\" : \"0fr\",\n                        opacity: count > 0 ? 1 : 0,\n                        transition: reduced ? \"none\" : `grid-template-columns 380ms ${MORPH}, opacity 260ms ${MORPH}`,\n                      }}\n                    >\n                      <span className=\"flex min-w-0 items-baseline overflow-hidden whitespace-nowrap tabular-nums\">\n                        &nbsp;·&nbsp;\n                        <NumberRoll value={Math.max(count, 1)} duration={500} />\n                      </span>\n                    </span>\n                    {count > 0 && <span className=\"sr-only\">, {count} selected</span>}\n                  </button>\n                )}\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}},"@keyframes ui-ping":{"from":{"transform":"scale(1)","opacity":"0.55"},"to":{"transform":"scale(2.6)","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/clarifying-question"}