{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"web-research","type":"registry:component","title":"Web research","description":"Watch the agent search: the query types in, page icons stack up as it reads, then it folds into a trail of searches and the pages each one found.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/web-research.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDown, ChevronRight, Search } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * WEB RESEARCH: watch the agent search and read\n *\n *   searching  \"Searching\" shimmers; the live query types in\n *   reading    the label morphs to \"Reading\" and \"7 sites\"\n *              rolls up; each page drops its icon into the\n *              stack (the oldest folds away) while its title\n *              rises in underneath as the last one lifts off\n *   done       the line morphs into \"3 searches · 9 sources\":\n *              counts roll, words morph, the title row folds\n *              shut and a chevron opens in; open it for the list\n *   error      \"Search failed\", keeping whatever was found\n *   cancelled  \"Stopped after 2 searches\"\n *   trail      opened, each search is a row on a hairline trail\n *              with its count; a search on one site wears that\n *              site's mark, and the pages it found sit under it\n *              as overlapping marks that fold open into links\n *\n * Feed it the queries and sources as they arrive (tool calls\n * and source parts from the AI SDK); it animates the rest.\n * ───────────────────────────────────────────────────────── */\n\nexport type ResearchStatus = \"searching\" | \"reading\" | \"done\" | \"error\" | \"cancelled\";\n\nexport interface ResearchSource {\n  url: string;\n  title?: string;\n  /** An icon URL; without one, a tinted monogram of the site. */\n  favicon?: string;\n  /** Which search found it (its index in queries), so the trail can group pages under their search. */\n  query?: number;\n}\n\nexport interface ResearchQuery {\n  text: string;\n  /** A search on one site, e.g. \"reddit.com\": the row wears that site's mark and reads \"Searched Reddit for ...\". */\n  site?: string;\n  /** An icon URL for that site. */\n  siteIcon?: string;\n  /** How many it found. */\n  results?: number;\n  /** The word after the count, e.g. \"posts\". Defaults to \"results\". */\n  resultLabel?: string;\n}\n\nexport interface WebResearchProps extends React.HTMLAttributes<HTMLDivElement> {\n  status: ResearchStatus;\n  /** Queries so far, as text or with their site and count; the last one is live while searching. */\n  queries: (string | ResearchQuery)[];\n  /** Pages read so far, in order. */\n  sources: ResearchSource[];\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}\n\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst STACK = 4;\n// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.\nconst SHEEN =\n  \"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70\";\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(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\nfunction hostname(url: string) {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n}\n\nfunction hue(text: string) {\n  let h = 0;\n  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) % 360;\n  return h;\n}\n\nconst asQuery = (q: string | ResearchQuery): ResearchQuery => (typeof q === \"string\" ? { text: q } : q);\nconst siteName = (site: string) => {\n  const parts = hostname(`https://${site}`).split(\".\");\n  const name = parts.length > 1 ? parts[parts.length - 2] : parts[0];\n  return name.length <= 2 ? name.toUpperCase() : name.charAt(0).toUpperCase() + name.slice(1);\n};\n\nconst plural = (n: number, word: string) => `${word}${n === 1 ? \"\" : /(s|sh|ch|x)$/.test(word) ? \"es\" : \"s\"}`;\n\nfunction Favicon({ source, className = \"\" }: { source: ResearchSource; className?: string }) {\n  const host = hostname(source.url);\n  const [failed, setFailed] = React.useState(false);\n  if (source.favicon && !failed) {\n    return <img src={source.favicon} alt=\"\" onError={() => setFailed(true)} className={`rounded-full bg-background object-cover ${className}`} />;\n  }\n  const h = hue(host);\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={`flex items-center justify-center rounded-full text-[9.5px] font-semibold leading-none ${className}`}\n      style={{\n        background: `color-mix(in oklab, oklch(0.62 0.13 ${h}) 20%, var(--background))`,\n        color: `oklch(0.5 0.12 ${h})`,\n      }}\n    >\n      {host.charAt(0).toUpperCase()}\n    </span>\n  );\n}\n\n/** Types the newest query in, a few characters a frame; earlier text never re-types. */\nfunction useTyped(text: string, active: boolean, reduced: boolean) {\n  const [shown, setShown] = React.useState(text);\n  React.useEffect(() => {\n    if (!active || reduced) {\n      setShown(text);\n      return;\n    }\n    let i = 0;\n    let frame = 0;\n    const step = () => {\n      i = Math.min(text.length, i + 2);\n      setShown(text.slice(0, i));\n      if (i < text.length) frame = requestAnimationFrame(step);\n    };\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n  }, [text, active, reduced]);\n  return shown;\n}\n\n/** A piece of the line that opens out of nothing and folds back into it (width eases, content blurs in). */\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 min-w-0 ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(4px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 460ms ${MORPH}, opacity ${show ? \"320ms\" : \"180ms\"} ${MORPH}, filter 320ms ${MORPH}`,\n      }}\n    >\n      <span className=\"min-w-0 whitespace-nowrap [clip-path:inset(-4px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\n/** One icon in the stack: drops in when it's new, folds away when it's pushed out by a newer one. */\nfunction StackIcon({ shown, first, enter, reduced, z, children }: { shown: boolean; first: boolean; enter: boolean; reduced: boolean; z: number; children: React.ReactNode }) {\n  const [entering] = React.useState(enter);\n  return (\n    <span\n      aria-hidden={!shown || undefined}\n      className={`relative flex h-5 shrink-0 ${entering && !reduced ? \"animate-[ui-drop-in_420ms_cubic-bezier(0.23,1,0.32,1)_backwards]\" : \"\"}`}\n      style={{\n        zIndex: z,\n        width: shown ? 20 : 0,\n        marginLeft: shown && !first ? -6 : 0,\n        opacity: shown ? 1 : 0,\n        transform: shown ? \"none\" : \"scale(0.6)\",\n        transition: reduced ? \"none\" : `width 420ms ${MORPH}, margin 420ms ${MORPH}, opacity 260ms ${MORPH}, transform 420ms ${MORPH}`,\n      }}\n    >\n      <span className=\"flex size-5 shrink-0 overflow-hidden rounded-full ring-2 ring-background\">{children}</span>\n    </span>\n  );\n}\n\n/** A one-line ticker: the new text rises in out of a blur while the last one lifts away. */\nfunction Ticker({ id, reduced, children }: { id: string; reduced: boolean; children: React.ReactNode }) {\n  const [current, setCurrent] = React.useState<{ id: string; node: React.ReactNode }>({ id, node: children });\n  const [leaving, setLeaving] = React.useState<{ id: string; node: React.ReactNode }[]>([]);\n  if (current.id !== id) {\n    if (!reduced) setLeaving((l) => (l.some((x) => x.id === current.id) ? l : [...l, current]));\n    setCurrent({ id, node: children });\n  }\n  const done = React.useCallback((leftId: string) => setLeaving((l) => l.filter((x) => x.id !== leftId)), []);\n  const first = React.useRef(true);\n  React.useEffect(() => {\n    first.current = false;\n  }, []);\n\n  return (\n    <span className=\"relative block h-4\">\n      {leaving.map((l) => (\n        <Tick key={`out-${l.id}`} id={l.id} out onDone={done}>\n          {l.node}\n        </Tick>\n      ))}\n      <Tick key={id} id={id} enter={!first.current && !reduced}>\n        {children}\n      </Tick>\n    </span>\n  );\n}\n\nfunction Tick({ id, out = false, enter = false, onDone, children }: { id: string; out?: boolean; enter?: boolean; onDone?: (id: string) => void; children: React.ReactNode }) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const [entering] = React.useState(enter);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    if (out) {\n      const animation = el.animate(\n        [\n          { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n          { opacity: 0, filter: \"blur(4px)\", transform: \"translateY(-6px)\" },\n        ],\n        { duration: 220, easing: MORPH, fill: \"forwards\" }\n      );\n      animation.onfinish = () => onDone?.(id);\n      return () => animation.cancel();\n    }\n    if (!entering) return;\n    const animation = el.animate(\n      [\n        { opacity: 0, filter: \"blur(4px)\", transform: \"translateY(6px)\" },\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n      ],\n      { duration: 380, easing: MORPH, delay: 60, fill: \"backwards\" }\n    );\n    return () => animation.cancel();\n  }, [id, out, entering, onDone]);\n  return (\n    <span ref={ref} aria-hidden={out || undefined} className=\"absolute inset-0 truncate text-[11.5px] text-muted-foreground/80\">\n      {children}\n    </span>\n  );\n}\n\nfunction SourceLink({ source }: { source: ResearchSource }) {\n  return (\n    <a\n      href={source.url}\n      target=\"_blank\"\n      rel=\"noreferrer\"\n      className=\"flex h-7 items-center gap-2.5 rounded-lg px-1.5 outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40\"\n    >\n      <Favicon source={source} className=\"size-4 shrink-0\" />\n      <span className=\"min-w-0 flex-1 truncate text-[12.5px] text-foreground\">{source.title ?? hostname(source.url)}</span>\n      <span className=\"shrink-0 text-[11.5px] text-muted-foreground\">{hostname(source.url)}</span>\n    </a>\n  );\n}\n\n/** The pages one search found: overlapping marks on a branch of the trail, folding open into links. */\nfunction SourceGroup({ sources, reduced }: { sources: ResearchSource[]; reduced: boolean }) {\n  const [open, setOpen] = React.useState(false);\n  const listId = React.useId();\n  const shown = sources.slice(0, 5);\n  return (\n    <div className=\"relative mb-1 ml-6\">\n      <span aria-hidden=\"true\" className=\"absolute -left-3.5 -top-1 h-[13px] w-2.5 rounded-bl-[5px] border-b border-l border-border\" />\n      <button\n        type=\"button\"\n        aria-expanded={open}\n        aria-controls={listId}\n        onClick={() => setOpen((o) => !o)}\n        className=\"group flex h-6 items-center gap-2 rounded-md text-[12px] text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40\"\n      >\n        Sources\n        <span className=\"flex items-center\">\n          {shown.map((src, i) => (\n            <span key={src.url} className=\"flex size-4 overflow-hidden rounded-full ring-2 ring-background\" style={{ marginLeft: i ? -5 : 0, zIndex: shown.length - i }}>\n              <Favicon source={src} className=\"size-full\" />\n            </span>\n          ))}\n          {sources.length > shown.length && <span className=\"ml-1 font-mono text-[10px]\">+{sources.length - shown.length}</span>}\n        </span>\n        <ChevronDown\n          aria-hidden=\"true\"\n          className=\"size-3.5\"\n          style={{ transform: open ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}\n        />\n        <span className=\"sr-only\">({sources.length})</span>\n      </button>\n      <div\n        id={listId}\n        inert={!open}\n        className=\"grid\"\n        style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", opacity: open ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}, opacity ${open ? \"280ms\" : \"140ms\"} ${EASE}` }}\n      >\n        <ul className=\"-mx-1.5 min-h-0 overflow-hidden\">\n          {sources.map((src) => (\n            <li key={src.url}>\n              <SourceLink source={src} />\n            </li>\n          ))}\n        </ul>\n      </div>\n    </div>\n  );\n}\n\nexport function WebResearch({ status, queries, sources, open: openProp, defaultOpen = false, onOpenChange, className = \"\", ...props }: WebResearchProps) {\n  const reduced = useReducedMotion();\n  const [ownOpen, setOwnOpen] = React.useState(defaultOpen);\n  const open = openProp ?? ownOpen;\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setOwnOpen(next);\n    onOpenChange?.(next);\n  };\n\n  const working = status === \"searching\" || status === \"reading\";\n  const query = queries.length ? asQuery(queries[queries.length - 1]).text : \"\";\n  const typed = useTyped(query, status === \"searching\", reduced);\n  const latest = sources[sources.length - 1];\n  const extra = Math.max(0, sources.length - STACK);\n  // Pages that don't say which search found them are listed after the trail.\n  const ungrouped = sources.filter((src) => src.query === undefined || src.query < 0 || src.query >= queries.length);\n  const listId = React.useId();\n\n  // Icons that arrive after the first paint drop in; the ones already there just sit.\n  const mounted = React.useRef(false);\n  React.useEffect(() => {\n    mounted.current = true;\n  }, []);\n\n  // One line that rearranges itself: a label, then whichever counts this state needs.\n  const label = { searching: \"Searching\", reading: \"Reading\", error: \"Search failed\", cancelled: \"Stopped after\", done: \"\" }[status];\n  const showSearches = status === \"done\" || status === \"cancelled\";\n  const showSources = sources.length > 0 && (status === \"reading\" || status === \"done\" || status === \"error\");\n  const showDot = showSources && (status === \"done\" || status === \"error\");\n  const sourceWord = status === \"reading\" ? plural(sources.length, \"site\") : plural(sources.length, \"source\");\n\n  const summary =\n    status === \"searching\"\n      ? `Searching for ${query}`\n      : status === \"reading\"\n        ? `Read ${sources.length} ${sourceWord}`\n        : status === \"error\"\n          ? `Search failed${sources.length ? `, ${sources.length} ${sourceWord}` : \"\"}`\n          : status === \"cancelled\"\n            ? `Stopped after ${queries.length} ${plural(queries.length, \"search\")}`\n            : `Done: ${queries.length} ${plural(queries.length, \"search\")}, ${sources.length} ${sourceWord}`;\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      {/* The same row from first search to final summary, so every piece of it can morph; it becomes a button once there's a list to open. */}\n      <button\n        type=\"button\"\n        disabled={working}\n        aria-expanded={working ? undefined : open}\n        aria-controls={working ? undefined : listId}\n        onClick={() => setOpen(!open)}\n        className=\"group flex min-h-9 w-full items-center gap-2.5 rounded-lg py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-default\"\n      >\n        <span aria-hidden=\"true\" className=\"relative flex h-5 shrink-0 items-center\">\n          <StackIcon shown={sources.length === 0} first enter={false} reduced={reduced} z={0}>\n            <span className=\"flex size-full items-center justify-center bg-muted text-muted-foreground\">\n              <Search className={`size-3 ${working ? \"animate-[ui-breathe_1.6s_ease-in-out_infinite] motion-reduce:animate-none\" : \"\"}`} strokeWidth={2.2} />\n            </span>\n          </StackIcon>\n          {sources.map((s, i) => (\n            <StackIcon key={s.url} shown={i >= sources.length - STACK} first={i === Math.max(0, sources.length - STACK)} enter={mounted.current && working} reduced={reduced} z={i + 1}>\n              <Favicon source={s} className=\"size-full\" />\n            </StackIcon>\n          ))}\n          <Reveal show={extra > 0} reduced={reduced} className=\"relative z-[999]\">\n            <span className=\"ml-[-6px] flex h-5 min-w-5 items-center justify-center rounded-full bg-muted px-1 font-mono text-[9.5px] text-muted-foreground ring-2 ring-background\">\n              <NumberRoll value={extra} prefix=\"+\" duration={600} />\n            </span>\n          </Reveal>\n        </span>\n\n        <span className=\"min-w-0 flex-1 text-left\">\n          <span\n            className={`flex min-w-0 items-center text-[13px] leading-5 transition-colors duration-300 ${status === \"error\" ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}\n          >\n            <span className={`shrink-0 font-medium transition-colors duration-300 ${working ? SHEEN : \"\"}`}>\n              <TextMorph>{label}</TextMorph>\n            </span>\n            <Reveal show={showSearches} reduced={reduced} className=\"shrink-0\">\n              <span className=\"inline-flex items-baseline transition-[padding] duration-500\" style={{ paddingLeft: label ? 5 : 0, transitionTimingFunction: MORPH }}>\n                <NumberRoll value={queries.length} duration={700} />\n                &nbsp;\n                <TextMorph>{plural(queries.length, \"search\")}</TextMorph>\n              </span>\n            </Reveal>\n            <Reveal show={showDot} reduced={reduced} className=\"shrink-0\">\n              <span className=\"px-1.5 opacity-60\">·</span>\n            </Reveal>\n            <Reveal show={showSources} reduced={reduced} className=\"shrink-0\">\n              <span className=\"inline-flex items-baseline transition-[padding] duration-500\" style={{ paddingLeft: showDot ? 0 : 5, transitionTimingFunction: MORPH }}>\n                <NumberRoll value={sources.length} duration={700} />\n                &nbsp;\n                <TextMorph>{sourceWord}</TextMorph>\n              </span>\n            </Reveal>\n            <Reveal show={status === \"searching\" && Boolean(query)} reduced={reduced} className=\"shrink\">\n              <span className=\"block truncate pl-1.5\">“{typed}”</span>\n            </Reveal>\n          </span>\n          {/* The page being read; the row folds shut when the work is done. */}\n          <span\n            aria-hidden={!working || undefined}\n            className=\"grid\"\n            style={{\n              gridTemplateRows: working && latest ? \"1fr\" : \"0fr\",\n              opacity: working && latest ? 1 : 0,\n              transition: reduced ? \"none\" : `grid-template-rows 420ms ${MORPH}, opacity 260ms ${MORPH}`,\n            }}\n          >\n            <span className=\"min-h-0 overflow-hidden\">\n              <span className=\"block pt-0.5\">\n                {latest && (\n                  <Ticker id={latest.url} reduced={reduced}>\n                    {latest.title ?? hostname(latest.url)} <span className=\"text-muted-foreground/60\">· {hostname(latest.url)}</span>\n                  </Ticker>\n                )}\n              </span>\n            </span>\n          </span>\n        </span>\n\n        <Reveal show={!working} reduced={reduced} className=\"shrink-0\">\n          <ChevronRight\n            aria-hidden=\"true\"\n            className=\"size-3.5 text-muted-foreground transition-transform duration-300 group-hover:text-foreground motion-reduce:transition-none\"\n            style={{\n              transform: open ? \"rotate(90deg)\" : \"none\",\n              transitionTimingFunction: EASE,\n            }}\n          />\n        </Reveal>\n      </button>\n      {/* Outside the button, so the button's name stays its visible text. */}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {summary}\n      </span>\n\n      <div\n        id={listId}\n        inert={!open || working}\n        className=\"grid\"\n        style={{\n          gridTemplateRows: open && !working ? \"1fr\" : \"0fr\",\n          transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}`,\n        }}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <div className=\"ml-[9px] mt-1 pl-[19px]\">\n            {queries.length > 0 && (\n              <ul className=\"pb-1\">\n                {queries.map((raw, i) => {\n                  const q = asQuery(raw);\n                  const found = sources.filter((src) => src.query === i);\n                  const last = i === queries.length - 1 && ungrouped.length === 0;\n                  return (\n                    <li key={i} className=\"relative\">\n                      {/* The trail: a hairline down the left, a short branch to each search. */}\n                      <span aria-hidden=\"true\" className=\"absolute -left-[19px] top-0 w-px bg-border\" style={last ? { height: 15 } : { bottom: 0 }} />\n                      <span aria-hidden=\"true\" className=\"absolute -left-[19px] top-[15px] h-px w-3 bg-border\" />\n                      <div className=\"flex items-start gap-2 py-1.5 text-[12.5px] leading-[18px] text-muted-foreground\">\n                        <span className=\"mt-px flex size-4 shrink-0 items-center justify-center\">\n                          {q.site ? (\n                            <Favicon source={{ url: `https://${q.site}`, favicon: q.siteIcon }} className=\"size-4\" />\n                          ) : (\n                            <Search aria-hidden=\"true\" className=\"size-3.5\" strokeWidth={2.1} />\n                          )}\n                        </span>\n                        <span className=\"min-w-0 flex-1 text-foreground/85\">\n                          {q.site ? (\n                            <>\n                              Searched {siteName(q.site)} for <span className=\"font-mono text-[11.5px] text-muted-foreground\">{q.text}</span>\n                            </>\n                          ) : (\n                            q.text\n                          )}\n                        </span>\n                        {q.results != null && (\n                          <span className=\"shrink-0 whitespace-nowrap tabular-nums\">\n                            {q.results} {q.resultLabel ?? plural(q.results, \"result\")}\n                          </span>\n                        )}\n                      </div>\n                      {found.length > 0 && <SourceGroup sources={found} reduced={reduced} />}\n                    </li>\n                  );\n                })}\n              </ul>\n            )}\n            {ungrouped.length > 0 && (\n              <div className={queries.length ? \"border-t border-border/60 pb-1 pt-1.5\" : \"pb-1\"}>\n                <ul className=\"-mx-1.5 max-h-80 overflow-y-auto overflow-x-hidden px-1.5 [scrollbar-color:var(--border)_transparent] [scrollbar-width:thin]\">\n                  {ungrouped.map((src) => (\n                    <li key={src.url}>\n                      <SourceLink source={src} />\n                    </li>\n                  ))}\n                </ul>\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-sheen":{"from":{"mask-position":"150% 0"},"to":{"mask-position":"-50% 0"}},"@keyframes ui-breathe":{"0%, 100%":{"transform":"scale(0.82)","opacity":"0.55"},"50%":{"transform":"scale(1)","opacity":"1"}},"@keyframes ui-drop-in":{"from":{"opacity":"0","transform":"translateY(-8px) scale(0.6)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/web-research"}