{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "search-scan",
  "type": "registry:component",
  "title": "Search Scan",
  "description": "Empty-state art for search: a magnifier reads down a page, zooming the lines under its lens, until it lands on the word you typed and highlights it.",
  "categories": [
    "illustration",
    "Empty States"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/search-scan.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { SearchIcon } from \"lucide-react\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SearchScanHue = \"sky\" | \"emerald\" | \"amber\" | \"violet\" | \"rose\" | \"orange\" | \"indigo\" | \"teal\";\n\nconst hues: Record<SearchScanHue, string> = {\n  sky: \"[--hue:var(--color-sky-500)] [--hue-deep:var(--color-sky-700)]\",\n  emerald: \"[--hue:var(--color-emerald-500)] [--hue-deep:var(--color-emerald-700)]\",\n  amber: \"[--hue:var(--color-amber-500)] [--hue-deep:var(--color-amber-700)]\",\n  violet: \"[--hue:var(--color-violet-500)] [--hue-deep:var(--color-violet-700)]\",\n  rose: \"[--hue:var(--color-rose-500)] [--hue-deep:var(--color-rose-700)]\",\n  orange: \"[--hue:var(--color-orange-500)] [--hue-deep:var(--color-orange-700)]\",\n  indigo: \"[--hue:var(--color-indigo-500)] [--hue-deep:var(--color-indigo-700)]\",\n  teal: \"[--hue:var(--color-teal-500)] [--hue-deep:var(--color-teal-700)]\",\n};\n\nexport interface SearchScanProps {\n  query?: string;\n  title?: string;\n  description?: string;\n  hue?: SearchScanHue;\n  className?: string;\n}\n\nconst W = 400;\nconst H = 250;\nconst PAGE = { x: 116, y: 22, w: 168, h: 212 };\nconst LENS_R = 32;\nconst ZOOM = 1.65;\n\nconst rows = [\n  [34, 22, 40, 28, 18],\n  [26, 44, 20, 36, 16],\n  [40, 18, 30, 24, 32],\n  [22, 36, 28, 42],\n  [30, 20, 38, 26, 22],\n  [44, 24, 18, 34],\n  [20, 32, 26, 30, 24],\n  [36, 28, 40],\n];\nconst ROW_Y0 = PAGE.y + 56;\nconst ROW_GAP = 17;\nconst LEFT = PAGE.x + 16;\nconst GAP = 5;\n\nconst matches: [number, number][] = [\n  [4, 2],\n  [6, 3],\n];\n\nfunction wordBox(row: number, word: number) {\n  const x = LEFT + rows[row].slice(0, word).reduce((n, w) => n + w + GAP, 0);\n  return { x, y: ROW_Y0 + row * ROW_GAP - 3, w: rows[row][word], h: 6 };\n}\n\nfunction Page({ match, found, query }: { match: [number, number]; found: boolean; query: string }) {\n  const box = wordBox(...match);\n  const glow = Math.max(box.w, query.length * 3.9) + 8;\n  const cx = box.x + box.w / 2;\n  return (\n    <g>\n      <rect className=\"fill-card stroke-foreground/12\" height={PAGE.h} rx={10} strokeWidth={1} width={PAGE.w} x={PAGE.x} y={PAGE.y} />\n      <rect className=\"fill-foreground/70\" height={8} rx={4} width={72} x={LEFT} y={PAGE.y + 18} />\n      <rect className=\"fill-foreground/20\" height={5} rx={2.5} width={46} x={LEFT} y={PAGE.y + 33} />\n      {rows.map((words, r) =>\n        words.map((w, i) => {\n          const b = wordBox(r, i);\n          const hit = r === match[0] && i === match[1];\n          return (\n            <rect\n              className={cn(\"fill-foreground/15 transition-opacity duration-200\", hit && found && \"opacity-0\")}\n              height={b.h}\n              key={`${r}-${i}`}\n              rx={3}\n              width={w}\n              x={b.x}\n              y={b.y}\n            />\n          );\n        }),\n      )}\n      <motion.rect\n        animate={{ opacity: found ? 1 : 0, scaleX: found ? 1 : 0.4 }}\n        className=\"fill-amber-200 dark:fill-amber-300\"\n        height={14}\n        initial={false}\n        rx={4}\n        style={{ originX: \"50%\" }}\n        transition={{ duration: found ? 0.35 : 0.25, ease: [0.23, 1, 0.32, 1] }}\n        width={glow}\n        x={cx - glow / 2}\n        y={box.y - 4}\n      />\n      <motion.text\n        animate={{ opacity: found ? 1 : 0 }}\n        className=\"fill-amber-900 font-semibold\"\n        dominantBaseline=\"central\"\n        fontSize={6.8}\n        initial={false}\n        textAnchor=\"middle\"\n        transition={{ duration: 0.2, delay: found ? 0.12 : 0 }}\n        x={cx}\n        y={box.y + 3}\n      >\n        {query}\n      </motion.text>\n    </g>\n  );\n}\n\nfunction useCalmMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport function SearchScan({\n  query = \"roadmap\",\n  title = \"Search across every doc\",\n  description = \"Type a word and we'll find it in your notes, specs and files.\",\n  hue = \"sky\",\n  className,\n}: SearchScanProps) {\n  const uid = useId().replace(/:/g, \"\");\n  const reduce = useCalmMotion();\n  const root = useRef<HTMLDivElement>(null);\n  const inView = useInView(root);\n  const [round, setRound] = useState(0);\n  const [found, setFound] = useState(false);\n  const [typed, setTyped] = useState(0);\n  const match = matches[round % matches.length];\n  const target = wordBox(...match);\n\n  const lx = useMotionValue(PAGE.x + 52);\n  const ly = useMotionValue(ROW_Y0 + 10);\n  const mx = useTransform(lx, (v) => v * (1 - ZOOM));\n  const my = useTransform(ly, (v) => v * (1 - ZOOM));\n  const lensScale = useMotionValue(1);\n\n  useEffect(() => {\n    if (reduce) {\n      lx.set(target.x + target.w / 2);\n      ly.set(target.y + 3);\n      setTyped(query.length);\n      setFound(true);\n      return;\n    }\n    if (!inView) return;\n    let alive = true;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const stops: { stop: () => void }[] = [];\n    const wait = (ms: number) => new Promise<void>((r) => timers.push(setTimeout(r, ms)));\n    const glide = async (x: number, y: number, duration: number) => {\n      const a = animate(lx, x, { duration, ease: [0.45, 0, 0.25, 1] });\n      const b = animate(ly, y, { duration, ease: [0.45, 0, 0.25, 1] });\n      stops.push(a, b);\n      await Promise.all([a, b]);\n    };\n\n    (async () => {\n      setFound(false);\n      setTyped(0);\n      for (let i = 1; i <= query.length; i++) {\n        await wait(70);\n        if (!alive) return;\n        setTyped(i);\n      }\n      await wait(250);\n      const right = PAGE.x + PAGE.w - 40;\n      const left = PAGE.x + 44;\n      const sweep = round % 2 === 0\n        ? [[right, ROW_Y0 + 8], [right, ROW_Y0 + 42], [left, ROW_Y0 + 42], [left, ROW_Y0 + 74]]\n        : [[right, ROW_Y0 + 10], [left, ROW_Y0 + 40], [right, ROW_Y0 + 76], [left, ROW_Y0 + 96]];\n      for (const [x, y] of sweep) {\n        await glide(x, y, 0.62);\n        if (!alive) return;\n      }\n      await glide(target.x + target.w / 2, target.y + 3, 0.7);\n      if (!alive) return;\n      setFound(true);\n      stops.push(animate(lensScale, [1, 1.08, 1], { duration: 0.42, ease: [0.23, 1, 0.32, 1] }));\n      await wait(2400);\n      if (!alive) return;\n      setFound(false);\n      await glide(PAGE.x + 52, ROW_Y0 + 10, 0.8);\n      await wait(700);\n      if (alive) setRound((r) => r + 1);\n    })();\n\n    return () => {\n      alive = false;\n      for (const t of timers) clearTimeout(t);\n      for (const s of stops) s.stop();\n    };\n  }, [round, reduce, inView, query.length, lx, ly, lensScale, target.x, target.y, target.w]);\n\n  return (\n    <div\n      ref={root}\n      className={cn(\n        \"relative w-full max-w-[440px] overflow-hidden rounded-2xl border bg-card p-4 shadow-xs/5 sm:p-6\",\n        hues[hue],\n        className,\n      )}\n    >\n      <div\n        aria-hidden\n        className=\"dot-grid pointer-events-none absolute inset-0 opacity-50 [mask-image:radial-gradient(closest-side,black,transparent)]\"\n      />\n      <div className=\"relative mx-auto mb-1 flex w-fit items-center gap-1.5 rounded-full border bg-popover py-1 pr-2.5 pl-2 text-xs shadow-xs/5\">\n        <SearchIcon aria-hidden className=\"size-3.5 text-muted-foreground\" />\n        <span className=\"inline-block min-w-[7ch] font-medium text-foreground\">\n          {query.slice(0, typed)}\n          {typed < query.length && <span className=\"ml-px inline-block h-3 w-px translate-y-0.5 animate-caret-blink bg-foreground\" />}\n        </span>\n        <motion.span\n          animate={{ opacity: found ? 1 : 0, filter: found ? \"blur(0px)\" : \"blur(2px)\" }}\n          className=\"whitespace-nowrap text-muted-foreground tabular-nums\"\n          initial={false}\n          transition={{ duration: 0.22, ease: [0.23, 1, 0.32, 1] }}\n        >\n          · 1 match\n        </motion.span>\n      </div>\n      <div className=\"relative mx-auto aspect-[400/232] w-full max-w-[400px]\">\n        <svg aria-hidden className=\"absolute inset-0 size-full overflow-visible\" viewBox={`0 12 ${W} ${H - 18}`}>\n          <defs>\n            <clipPath id={`${uid}-lens`}>\n              <motion.circle cx={lx} cy={ly} r={LENS_R - 3} />\n            </clipPath>\n          </defs>\n\n          <rect className=\"fill-card stroke-foreground/10\" height={PAGE.h - 16} rx={10} transform={`rotate(-6 ${PAGE.x + 40} ${PAGE.y + PAGE.h})`} width={PAGE.w - 10} x={PAGE.x - 10} y={PAGE.y + 14} />\n          <rect className=\"fill-card stroke-foreground/10\" height={PAGE.h - 8} rx={10} transform={`rotate(4 ${PAGE.x + PAGE.w} ${PAGE.y + PAGE.h})`} width={PAGE.w - 6} x={PAGE.x + 16} y={PAGE.y + 8} />\n          <Page found={found} match={match} query={query} />\n\n          <g clipPath={`url(#${uid}-lens)`}>\n            <motion.circle className=\"fill-card\" cx={lx} cy={ly} r={LENS_R} />\n            <motion.g style={{ x: mx, y: my }}>\n              <g transform={`scale(${ZOOM})`}>\n                <Page found={found} match={match} query={query} />\n              </g>\n            </motion.g>\n            <motion.circle className=\"fill-(--hue)\" cx={lx} cy={ly} opacity={0.06} r={LENS_R} />\n          </g>\n\n          <motion.g style={{ x: lx, y: ly }}>\n            <motion.g style={{ scale: lensScale }}>\n              <circle fill=\"none\" r={64} />\n              <rect className=\"fill-(--hue-deep)\" height={11} rx={5.5} transform=\"rotate(45)\" width={32} x={LENS_R - 1} y={-5.5} />\n              <rect className=\"fill-white/20\" height={3} rx={1.5} transform=\"rotate(45)\" width={22} x={LENS_R + 5} y={-3.5} />\n              <circle className=\"fill-none stroke-black/10\" cx={0} cy={2} r={LENS_R} strokeWidth={6} />\n              <circle className=\"fill-none stroke-(--hue)\" cx={0} cy={0} r={LENS_R} strokeWidth={6} />\n              <path className=\"fill-none stroke-white/45\" d={`M${-LENS_R + 9} -6 A ${LENS_R - 9} ${LENS_R - 9} 0 0 1 -6 ${-LENS_R + 9}`} strokeLinecap=\"round\" strokeWidth={3} />\n            </motion.g>\n          </motion.g>\n        </svg>\n\n      </div>\n\n      <div className=\"relative mt-3 text-center\">\n        <p className=\"text-balance font-medium text-foreground text-sm\">{title}</p>\n        <p className=\"mx-auto mt-1 max-w-[30ch] text-pretty text-muted-foreground text-xs\">{description}</p>\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}