{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["effects","colorful"],"dependencies":["lucide-react","motion"],"description":"Android's Circle to Search — enter search mode, lasso anything with a glowing stroke, and results slide up for the circled item.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { Search, Sparkles, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport { PhoneFrame } from \"./phone-frame\";\n\ninterface Item {\n  id: string;\n  name: string;\n  price: string;\n  hue: number;\n  result: string;\n}\n\nconst ITEMS: Item[] = [\n  {\n    hue: 38,\n    id: \"lamp\",\n    name: \"Arc lamp\",\n    price: \"$249\",\n    result: \"Arne floor lamp · from $219 at 4 stores\",\n  },\n  {\n    hue: 20,\n    id: \"chair\",\n    name: \"Lounge chair\",\n    price: \"$1,190\",\n    result: \"Teak lounge chair · $980–$1,320\",\n  },\n  {\n    hue: 150,\n    id: \"vase\",\n    name: \"Stone vase\",\n    price: \"$89\",\n    result: \"Travertine vase · $74 at 9 stores\",\n  },\n  { hue: 260, id: \"rug\", name: \"Wool rug\", price: \"$430\", result: \"Berber wool rug · from $389\" },\n];\n\ninterface Point {\n  x: number;\n  y: number;\n}\n\nexport const CircleToSearch = () => {\n  const [searchMode, setSearchMode] = useState(false);\n  const [points, setPoints] = useState<Point[]>([]);\n  const [match, setMatch] = useState<Item | null>(null);\n  const drawingRef = useRef(false);\n  const pointsRef = useRef<Point[]>([]);\n  const stageRef = useRef<HTMLDivElement | null>(null);\n  const cardRefs = useRef(new Map<string, HTMLDivElement>());\n  const reduceMotion = useReducedMotion() ?? false;\n\n  // Pointer events and layout rects are in viewport pixels, but the stroke and hit-test live in\n  // the stage's own CSS pixels; they diverge whenever an ancestor is transformed.\n  const localPoint = (clientX: number, clientY: number): Point | null => {\n    const stage = stageRef.current;\n    if (!stage) {\n      return null;\n    }\n    const rect = stage.getBoundingClientRect();\n    const scale = rect.width / stage.offsetWidth || 1;\n    return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale };\n  };\n\n  const finishDraw = () => {\n    if (!drawingRef.current) {\n      return;\n    }\n    drawingRef.current = false;\n    const drawn = pointsRef.current;\n    if (drawn.length < 8) {\n      pointsRef.current = [];\n      setPoints([]);\n      return;\n    }\n    const cx = drawn.reduce((sum, p) => sum + p.x, 0) / drawn.length;\n    const cy = drawn.reduce((sum, p) => sum + p.y, 0) / drawn.length;\n    for (const item of ITEMS) {\n      const node = cardRefs.current.get(item.id);\n      if (!node) {\n        continue;\n      }\n      const rect = node.getBoundingClientRect();\n      const topLeft = localPoint(rect.left, rect.top);\n      const bottomRight = localPoint(rect.right, rect.bottom);\n      if (!topLeft || !bottomRight) {\n        continue;\n      }\n      if (cx >= topLeft.x && cx <= bottomRight.x && cy >= topLeft.y && cy <= bottomRight.y) {\n        setMatch(item);\n        break;\n      }\n    }\n  };\n\n  const reset = () => {\n    setSearchMode(false);\n    pointsRef.current = [];\n    setPoints([]);\n    setMatch(null);\n  };\n\n  return (\n    <PhoneFrame tone=\"plum\" className=\"bg-[#130f19]\">\n      <div ref={stageRef} className=\"absolute inset-0\">\n        <div className=\"px-5 pt-[60px]\">\n          <p className=\"text-[20px] font-bold text-white\">Autumn lookbook</p>\n          <p className=\"text-[12px] text-white/40\">Editorial · 12 pieces</p>\n          <div className=\"mt-4 grid grid-cols-2 gap-3\">\n            {ITEMS.map((item) => (\n              <div\n                key={item.id}\n                ref={(node) => {\n                  if (node) {\n                    cardRefs.current.set(item.id, node);\n                  } else {\n                    cardRefs.current.delete(item.id);\n                  }\n                }}\n                className=\"overflow-hidden rounded-2xl ring-1 ring-white/[0.07] transition-shadow\"\n                style={\n                  match?.id === item.id\n                    ? { boxShadow: \"0 0 0 3px #a5b4fc, 0 8px 40px rgba(129,140,248,0.35)\" }\n                    : undefined\n                }\n              >\n                <div\n                  className=\"h-[148px]\"\n                  style={{\n                    background: `linear-gradient(135deg, hsl(${item.hue} 35% 45%), hsl(${item.hue} 45% 22%))`,\n                  }}\n                />\n                <div className=\"bg-white/[0.04] px-3 py-2\">\n                  <p className=\"text-[12px] font-semibold text-white\">{item.name}</p>\n                  <p className=\"text-[11px] text-white/40\">{item.price}</p>\n                </div>\n              </div>\n            ))}\n          </div>\n          <p className=\"mt-5 text-[12px] leading-relaxed text-white/45\">\n            Warm woods, washed stone and low light — the pieces we keep reaching for once the\n            evenings draw in.\n          </p>\n        </div>\n\n        <AnimatePresence>\n          {searchMode && (\n            <motion.div\n              key=\"mode\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              className=\"pointer-events-none absolute inset-0\"\n            >\n              <div className=\"absolute inset-0 bg-black/35\" />\n              <motion.div\n                aria-hidden\n                className=\"absolute inset-0 rounded-[50px] ring-2 ring-indigo-300/70\"\n                animate={{ opacity: reduceMotion ? 0.7 : [0.4, 0.9, 0.4] }}\n                transition={\n                  reduceMotion\n                    ? { duration: 0 }\n                    : { duration: 2, ease: \"easeInOut\", repeat: Infinity }\n                }\n              />\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        {searchMode && (\n          <div\n            className=\"absolute inset-0 z-20 cursor-crosshair touch-none\"\n            onPointerDown={(event) => {\n              if (match) {\n                return;\n              }\n              drawingRef.current = true;\n              event.currentTarget.setPointerCapture(event.pointerId);\n              const point = localPoint(event.clientX, event.clientY);\n              if (point) {\n                pointsRef.current = [point];\n                setPoints([point]);\n              }\n            }}\n            onPointerMove={(event) => {\n              if (!drawingRef.current) {\n                return;\n              }\n              const point = localPoint(event.clientX, event.clientY);\n              if (point) {\n                pointsRef.current = [...pointsRef.current, point];\n                setPoints(pointsRef.current);\n              }\n            }}\n            onPointerUp={finishDraw}\n            onPointerCancel={finishDraw}\n          >\n            <svg className=\"absolute inset-0 size-full\">\n              <defs>\n                <linearGradient id=\"cts-stroke\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n                  <stop offset=\"0%\" stopColor=\"#a5b4fc\" />\n                  <stop offset=\"50%\" stopColor=\"#f0abfc\" />\n                  <stop offset=\"100%\" stopColor=\"#67e8f9\" />\n                </linearGradient>\n              </defs>\n              {points.length > 1 && (\n                <polyline\n                  points={points.map((p) => `${p.x},${p.y}`).join(\" \")}\n                  fill=\"none\"\n                  stroke=\"url(#cts-stroke)\"\n                  strokeWidth=\"5\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  opacity=\"0.95\"\n                  style={{ filter: \"drop-shadow(0 0 10px rgba(165,180,252,0.8))\" }}\n                />\n              )}\n            </svg>\n          </div>\n        )}\n\n        <AnimatePresence>\n          {match && (\n            <motion.div\n              key=\"sheet\"\n              initial={{ y: \"110%\" }}\n              animate={{ y: 0 }}\n              exit={{ y: \"110%\" }}\n              transition={{ bounce: 0.18, duration: 0.5, type: \"spring\" }}\n              className=\"absolute inset-x-3 bottom-[76px] z-30 rounded-[26px] bg-[#211a29]/95 p-4 shadow-2xl shadow-black/60 ring-1 ring-white/10 backdrop-blur-xl\"\n            >\n              <div className=\"flex items-center gap-2 text-[11px] font-medium text-indigo-300\">\n                <Sparkles className=\"size-3.5\" /> Circle to Search\n              </div>\n              <div className=\"mt-2.5 flex items-center gap-3\">\n                <div\n                  className=\"size-12 shrink-0 rounded-xl\"\n                  style={{\n                    background: `linear-gradient(135deg, hsl(${match.hue} 35% 45%), hsl(${match.hue} 45% 22%))`,\n                  }}\n                />\n                <div className=\"min-w-0 flex-1\">\n                  <p className=\"text-[13px] font-semibold text-white\">{match.result}</p>\n                  <p className=\"mt-0.5 text-[11px] text-white/40\">Visual match · {match.name}</p>\n                </div>\n              </div>\n              <button\n                type=\"button\"\n                onClick={reset}\n                className=\"mt-3 w-full rounded-full bg-indigo-400/90 py-2 text-[12px] font-semibold text-indigo-950 transition-colors hover:bg-indigo-300\"\n              >\n                Done\n              </button>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <div className=\"absolute inset-x-0 bottom-7 z-40 flex justify-center\">\n          <motion.button\n            type=\"button\"\n            aria-pressed={searchMode}\n            onClick={() => (searchMode ? reset() : setSearchMode(true))}\n            whileTap={{ scale: 0.9 }}\n            className={`flex items-center gap-2 rounded-full px-4 py-2 text-[12px] font-medium ring-1 backdrop-blur-md transition-colors ${\n              searchMode\n                ? \"bg-indigo-400/20 text-indigo-200 ring-indigo-300/40\"\n                : \"bg-white/10 text-white/70 ring-white/15\"\n            }`}\n          >\n            {searchMode ? <X className=\"size-3.5\" /> : <Search className=\"size-3.5\" />}\n            {searchMode ? \"Exit search\" : \"Tap to search\"}\n          </motion.button>\n        </div>\n\n        {searchMode && !match && (\n          <p className=\"pointer-events-none absolute inset-x-0 bottom-[84px] z-20 text-center text-[12px] font-medium text-indigo-200/90\">\n            Circle anything\n          </p>\n        )}\n      </div>\n    </PhoneFrame>\n  );\n};\n","path":"/circle-to-search.tsx","target":"uicapsule/circle-to-search/circle-to-search.tsx","type":"registry:file"},{"content":"import type { ReactNode } from \"react\";\n\n// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.\nexport const SCREEN_WIDTH = 330;\nexport const SCREEN_HEIGHT = 716;\n\ninterface Clay {\n  base: string;\n  deep: string;\n  light: string;\n  /** RGB triplet for inner shading, tinted so each tone reads as one material. */\n  rim: string;\n}\n\n// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.\nconst TONES = {\n  espresso: { base: \"#3a2b24\", deep: \"#1f1612\", light: \"#5a4436\", rim: \"32 20 14\" },\n  graphite: { base: \"#303036\", deep: \"#18181c\", light: \"#4a4a52\", rim: \"10 10 14\" },\n  moss: { base: \"#2f3a2c\", deep: \"#171d15\", light: \"#4a5a44\", rim: \"12 18 10\" },\n  ochre: { base: \"#4a3c20\", deep: \"#261e0e\", light: \"#6e5a30\", rim: \"30 22 6\" },\n  plum: { base: \"#3c2a3a\", deep: \"#1e141d\", light: \"#5c4058\", rim: \"26 12 24\" },\n  slate: { base: \"#2a3448\", deep: \"#141a26\", light: \"#435270\", rim: \"10 16 30\" },\n  teal: { base: \"#233a3a\", deep: \"#101d1d\", light: \"#38585a\", rim: \"6 22 22\" },\n  terracotta: { base: \"#4a2c22\", deep: \"#26150f\", light: \"#6c4232\", rim: \"34 14 8\" },\n} satisfies Record<string, Clay>;\n\nexport type ClayTone = keyof typeof TONES;\n\nconst shellShadow = (rim: string) =>\n  [\n    \"0 60px 100px -40px rgb(0 0 0 / 0.85)\",\n    \"0 26px 40px -24px rgb(0 0 0 / 0.7)\",\n    \"inset 0 6px 6px rgb(255 255 255 / 0.16)\",\n    `inset 0 -14px 22px rgb(${rim} / 0.75)`,\n    \"inset 12px 0 18px -12px rgb(255 255 255 / 0.1)\",\n    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,\n  ].join(\", \");\n\nconst Nub = ({ className, clay }: { className: string; clay: Clay }) => (\n  <span\n    aria-hidden=\"true\"\n    className={`absolute w-[9px] rounded-full ${className}`}\n    style={{\n      background: clay.base,\n      boxShadow: `inset 2px 2px 2px rgb(255 255 255 / 0.14), inset -2px -2px 3px rgb(${clay.rim} / 0.7), 0 2px 4px rgb(0 0 0 / 0.5)`,\n    }}\n  />\n);\n\ninterface PhoneFrameProps {\n  children: ReactNode;\n  /** Classes for the screen: background, text color, pointer handling. */\n  className?: string;\n  /** Hide the Dynamic Island when the screen draws its own status area. */\n  island?: boolean;\n  tone?: ClayTone;\n}\n\nexport const PhoneFrame = ({\n  children,\n  className,\n  island = true,\n  tone = \"graphite\",\n}: PhoneFrameProps) => {\n  const clay = TONES[tone];\n  return (\n    <div\n      className=\"relative shrink-0 rounded-[66px] p-[16px]\"\n      style={{\n        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,\n        boxShadow: shellShadow(clay.rim),\n      }}\n    >\n      <Nub clay={clay} className=\"top-[132px] -left-[6px] h-[36px]\" />\n      <Nub clay={clay} className=\"top-[188px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[262px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[206px] -right-[6px] h-[92px]\" />\n      <div\n        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? \"bg-black\"}`}\n        style={{\n          boxShadow: `0 0 0 2px rgb(${clay.rim} / 0.9), 0 -2px 3px rgb(${clay.rim} / 0.8), 0 3px 4px rgb(255 255 255 / 0.12)`,\n          height: SCREEN_HEIGHT,\n          width: SCREEN_WIDTH,\n        }}\n      >\n        {children}\n        {island && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black\"\n          />\n        )}\n      </div>\n    </div>\n  );\n};\n","path":"/phone-frame.tsx","target":"uicapsule/circle-to-search/phone-frame.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { CircleToSearch } from \"./circle-to-search\";\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden bg-[radial-gradient(ellipse_120%_60%_at_50%_105%,#6b2458_0%,rgb(107_36_88/0)_65%),radial-gradient(ellipse_80%_50%_at_20%_0%,rgb(76_42_130/0.45)_0%,transparent_70%),linear-gradient(180deg,#120b1f_0%,#1f1230_50%,#2c1232_100%)] p-5\">\n    <CircleToSearch />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/circle-to-search/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/circle-to-search","name":"circle-to-search","registryDependencies":[],"title":"Circle to Search","type":"registry:block"}