# Circle to Search

> Android's Circle to Search — enter search mode, lasso anything with a glowing stroke, and results slide up for the circled item.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/circle-to-search).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/circle-to-search
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/circle-to-search-light-a87644066addd944.png
- Registry item: https://uicapsule.com/r/circle-to-search.json
- Categories: effects, colorful
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/circle-to-search.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`

## Files

### `/circle-to-search.tsx`

```tsx
"use client";

import { useRef, useState } from "react";
import { Search, Sparkles, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import { PhoneFrame } from "./phone-frame";

interface Item {
  id: string;
  name: string;
  price: string;
  hue: number;
  result: string;
}

const ITEMS: Item[] = [
  {
    hue: 38,
    id: "lamp",
    name: "Arc lamp",
    price: "$249",
    result: "Arne floor lamp · from $219 at 4 stores",
  },
  {
    hue: 20,
    id: "chair",
    name: "Lounge chair",
    price: "$1,190",
    result: "Teak lounge chair · $980–$1,320",
  },
  {
    hue: 150,
    id: "vase",
    name: "Stone vase",
    price: "$89",
    result: "Travertine vase · $74 at 9 stores",
  },
  { hue: 260, id: "rug", name: "Wool rug", price: "$430", result: "Berber wool rug · from $389" },
];

interface Point {
  x: number;
  y: number;
}

export const CircleToSearch = () => {
  const [searchMode, setSearchMode] = useState(false);
  const [points, setPoints] = useState<Point[]>([]);
  const [match, setMatch] = useState<Item | null>(null);
  const drawingRef = useRef(false);
  const pointsRef = useRef<Point[]>([]);
  const stageRef = useRef<HTMLDivElement | null>(null);
  const cardRefs = useRef(new Map<string, HTMLDivElement>());
  const reduceMotion = useReducedMotion() ?? false;

  // Pointer events and layout rects are in viewport pixels, but the stroke and hit-test live in
  // the stage's own CSS pixels; they diverge whenever an ancestor is transformed.
  const localPoint = (clientX: number, clientY: number): Point | null => {
    const stage = stageRef.current;
    if (!stage) {
      return null;
    }
    const rect = stage.getBoundingClientRect();
    const scale = rect.width / stage.offsetWidth || 1;
    return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale };
  };

  const finishDraw = () => {
    if (!drawingRef.current) {
      return;
    }
    drawingRef.current = false;
    const drawn = pointsRef.current;
    if (drawn.length < 8) {
      pointsRef.current = [];
      setPoints([]);
      return;
    }
    const cx = drawn.reduce((sum, p) => sum + p.x, 0) / drawn.length;
    const cy = drawn.reduce((sum, p) => sum + p.y, 0) / drawn.length;
    for (const item of ITEMS) {
      const node = cardRefs.current.get(item.id);
      if (!node) {
        continue;
      }
      const rect = node.getBoundingClientRect();
      const topLeft = localPoint(rect.left, rect.top);
      const bottomRight = localPoint(rect.right, rect.bottom);
      if (!topLeft || !bottomRight) {
        continue;
      }
      if (cx >= topLeft.x && cx <= bottomRight.x && cy >= topLeft.y && cy <= bottomRight.y) {
        setMatch(item);
        break;
      }
    }
  };

  const reset = () => {
    setSearchMode(false);
    pointsRef.current = [];
    setPoints([]);
    setMatch(null);
  };

  return (
    <PhoneFrame tone="plum" className="bg-[#130f19]">
      <div ref={stageRef} className="absolute inset-0">
        <div className="px-5 pt-[60px]">
          <p className="text-[20px] font-bold text-white">Autumn lookbook</p>
          <p className="text-[12px] text-white/40">Editorial · 12 pieces</p>
          <div className="mt-4 grid grid-cols-2 gap-3">
            {ITEMS.map((item) => (
              <div
                key={item.id}
                ref={(node) => {
                  if (node) {
                    cardRefs.current.set(item.id, node);
                  } else {
                    cardRefs.current.delete(item.id);
                  }
                }}
                className="overflow-hidden rounded-2xl ring-1 ring-white/[0.07] transition-shadow"
                style={
                  match?.id === item.id
                    ? { boxShadow: "0 0 0 3px #a5b4fc, 0 8px 40px rgba(129,140,248,0.35)" }
                    : undefined
                }
              >
                <div
                  className="h-[148px]"
                  style={{
                    background: `linear-gradient(135deg, hsl(${item.hue} 35% 45%), hsl(${item.hue} 45% 22%))`,
                  }}
                />
                <div className="bg-white/[0.04] px-3 py-2">
                  <p className="text-[12px] font-semibold text-white">{item.name}</p>
                  <p className="text-[11px] text-white/40">{item.price}</p>
                </div>
              </div>
            ))}
          </div>
          <p className="mt-5 text-[12px] leading-relaxed text-white/45">
            Warm woods, washed stone and low light — the pieces we keep reaching for once the
            evenings draw in.
          </p>
        </div>

        <AnimatePresence>
          {searchMode && (
            <motion.div
              key="mode"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              className="pointer-events-none absolute inset-0"
            >
              <div className="absolute inset-0 bg-black/35" />
              <motion.div
                aria-hidden
                className="absolute inset-0 rounded-[50px] ring-2 ring-indigo-300/70"
                animate={{ opacity: reduceMotion ? 0.7 : [0.4, 0.9, 0.4] }}
                transition={
                  reduceMotion
                    ? { duration: 0 }
                    : { duration: 2, ease: "easeInOut", repeat: Infinity }
                }
              />
            </motion.div>
          )}
        </AnimatePresence>

        {searchMode && (
          <div
            className="absolute inset-0 z-20 cursor-crosshair touch-none"
            onPointerDown={(event) => {
              if (match) {
                return;
              }
              drawingRef.current = true;
              event.currentTarget.setPointerCapture(event.pointerId);
              const point = localPoint(event.clientX, event.clientY);
              if (point) {
                pointsRef.current = [point];
                setPoints([point]);
              }
            }}
            onPointerMove={(event) => {
              if (!drawingRef.current) {
                return;
              }
              const point = localPoint(event.clientX, event.clientY);
              if (point) {
                pointsRef.current = [...pointsRef.current, point];
                setPoints(pointsRef.current);
              }
            }}
            onPointerUp={finishDraw}
            onPointerCancel={finishDraw}
          >
            <svg className="absolute inset-0 size-full">
              <defs>
                <linearGradient id="cts-stroke" x1="0" y1="0" x2="1" y2="1">
                  <stop offset="0%" stopColor="#a5b4fc" />
                  <stop offset="50%" stopColor="#f0abfc" />
                  <stop offset="100%" stopColor="#67e8f9" />
                </linearGradient>
              </defs>
              {points.length > 1 && (
                <polyline
                  points={points.map((p) => `${p.x},${p.y}`).join(" ")}
                  fill="none"
                  stroke="url(#cts-stroke)"
                  strokeWidth="5"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  opacity="0.95"
                  style={{ filter: "drop-shadow(0 0 10px rgba(165,180,252,0.8))" }}
                />
              )}
            </svg>
          </div>
        )}

        <AnimatePresence>
          {match && (
            <motion.div
              key="sheet"
              initial={{ y: "110%" }}
              animate={{ y: 0 }}
              exit={{ y: "110%" }}
              transition={{ bounce: 0.18, duration: 0.5, type: "spring" }}
              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"
            >
              <div className="flex items-center gap-2 text-[11px] font-medium text-indigo-300">
                <Sparkles className="size-3.5" /> Circle to Search
              </div>
              <div className="mt-2.5 flex items-center gap-3">
                <div
                  className="size-12 shrink-0 rounded-xl"
                  style={{
                    background: `linear-gradient(135deg, hsl(${match.hue} 35% 45%), hsl(${match.hue} 45% 22%))`,
                  }}
                />
                <div className="min-w-0 flex-1">
                  <p className="text-[13px] font-semibold text-white">{match.result}</p>
                  <p className="mt-0.5 text-[11px] text-white/40">Visual match · {match.name}</p>
                </div>
              </div>
              <button
                type="button"
                onClick={reset}
                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"
              >
                Done
              </button>
            </motion.div>
          )}
        </AnimatePresence>

        <div className="absolute inset-x-0 bottom-7 z-40 flex justify-center">
          <motion.button
            type="button"
            aria-pressed={searchMode}
            onClick={() => (searchMode ? reset() : setSearchMode(true))}
            whileTap={{ scale: 0.9 }}
            className={`flex items-center gap-2 rounded-full px-4 py-2 text-[12px] font-medium ring-1 backdrop-blur-md transition-colors ${
              searchMode
                ? "bg-indigo-400/20 text-indigo-200 ring-indigo-300/40"
                : "bg-white/10 text-white/70 ring-white/15"
            }`}
          >
            {searchMode ? <X className="size-3.5" /> : <Search className="size-3.5" />}
            {searchMode ? "Exit search" : "Tap to search"}
          </motion.button>
        </div>

        {searchMode && !match && (
          <p className="pointer-events-none absolute inset-x-0 bottom-[84px] z-20 text-center text-[12px] font-medium text-indigo-200/90">
            Circle anything
          </p>
        )}
      </div>
    </PhoneFrame>
  );
};
```

### `/phone-frame.tsx`

```tsx
import type { ReactNode } from "react";

// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.
export const SCREEN_WIDTH = 330;
export const SCREEN_HEIGHT = 716;

interface Clay {
  base: string;
  deep: string;
  light: string;
  /** RGB triplet for inner shading, tinted so each tone reads as one material. */
  rim: string;
}

// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.
const TONES = {
  espresso: { base: "#3a2b24", deep: "#1f1612", light: "#5a4436", rim: "32 20 14" },
  graphite: { base: "#303036", deep: "#18181c", light: "#4a4a52", rim: "10 10 14" },
  moss: { base: "#2f3a2c", deep: "#171d15", light: "#4a5a44", rim: "12 18 10" },
  ochre: { base: "#4a3c20", deep: "#261e0e", light: "#6e5a30", rim: "30 22 6" },
  plum: { base: "#3c2a3a", deep: "#1e141d", light: "#5c4058", rim: "26 12 24" },
  slate: { base: "#2a3448", deep: "#141a26", light: "#435270", rim: "10 16 30" },
  teal: { base: "#233a3a", deep: "#101d1d", light: "#38585a", rim: "6 22 22" },
  terracotta: { base: "#4a2c22", deep: "#26150f", light: "#6c4232", rim: "34 14 8" },
} satisfies Record<string, Clay>;

export type ClayTone = keyof typeof TONES;

const shellShadow = (rim: string) =>
  [
    "0 60px 100px -40px rgb(0 0 0 / 0.85)",
    "0 26px 40px -24px rgb(0 0 0 / 0.7)",
    "inset 0 6px 6px rgb(255 255 255 / 0.16)",
    `inset 0 -14px 22px rgb(${rim} / 0.75)`,
    "inset 12px 0 18px -12px rgb(255 255 255 / 0.1)",
    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,
  ].join(", ");

const Nub = ({ className, clay }: { className: string; clay: Clay }) => (
  <span
    aria-hidden="true"
    className={`absolute w-[9px] rounded-full ${className}`}
    style={{
      background: clay.base,
      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)`,
    }}
  />
);

interface PhoneFrameProps {
  children: ReactNode;
  /** Classes for the screen: background, text color, pointer handling. */
  className?: string;
  /** Hide the Dynamic Island when the screen draws its own status area. */
  island?: boolean;
  tone?: ClayTone;
}

export const PhoneFrame = ({
  children,
  className,
  island = true,
  tone = "graphite",
}: PhoneFrameProps) => {
  const clay = TONES[tone];
  return (
    <div
      className="relative shrink-0 rounded-[66px] p-[16px]"
      style={{
        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,
        boxShadow: shellShadow(clay.rim),
      }}
    >
      <Nub clay={clay} className="top-[132px] -left-[6px] h-[36px]" />
      <Nub clay={clay} className="top-[188px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[262px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[206px] -right-[6px] h-[92px]" />
      <div
        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? "bg-black"}`}
        style={{
          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)`,
          height: SCREEN_HEIGHT,
          width: SCREEN_WIDTH,
        }}
      >
        {children}
        {island && (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black"
          />
        )}
      </div>
    </div>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { CircleToSearch } from "./circle-to-search";

const Preview = () => (
  <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">
    <CircleToSearch />
  </main>
);

export default Preview;
```
