"use client"; import { SearchIcon } from "lucide-react"; import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react"; import { useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type SearchScanHue = "sky" | "emerald" | "amber" | "violet" | "rose" | "orange" | "indigo" | "teal"; const hues: Record = { sky: "[--hue:var(--color-sky-500)] [--hue-deep:var(--color-sky-700)]", emerald: "[--hue:var(--color-emerald-500)] [--hue-deep:var(--color-emerald-700)]", amber: "[--hue:var(--color-amber-500)] [--hue-deep:var(--color-amber-700)]", violet: "[--hue:var(--color-violet-500)] [--hue-deep:var(--color-violet-700)]", rose: "[--hue:var(--color-rose-500)] [--hue-deep:var(--color-rose-700)]", orange: "[--hue:var(--color-orange-500)] [--hue-deep:var(--color-orange-700)]", indigo: "[--hue:var(--color-indigo-500)] [--hue-deep:var(--color-indigo-700)]", teal: "[--hue:var(--color-teal-500)] [--hue-deep:var(--color-teal-700)]", }; export interface SearchScanProps { query?: string; title?: string; description?: string; hue?: SearchScanHue; className?: string; } const W = 400; const H = 250; const PAGE = { x: 116, y: 22, w: 168, h: 212 }; const LENS_R = 32; const ZOOM = 1.65; const rows = [ [34, 22, 40, 28, 18], [26, 44, 20, 36, 16], [40, 18, 30, 24, 32], [22, 36, 28, 42], [30, 20, 38, 26, 22], [44, 24, 18, 34], [20, 32, 26, 30, 24], [36, 28, 40], ]; const ROW_Y0 = PAGE.y + 56; const ROW_GAP = 17; const LEFT = PAGE.x + 16; const GAP = 5; const matches: [number, number][] = [ [4, 2], [6, 3], ]; function wordBox(row: number, word: number) { const x = LEFT + rows[row].slice(0, word).reduce((n, w) => n + w + GAP, 0); return { x, y: ROW_Y0 + row * ROW_GAP - 3, w: rows[row][word], h: 6 }; } function Page({ match, found, query }: { match: [number, number]; found: boolean; query: string }) { const box = wordBox(...match); const glow = Math.max(box.w, query.length * 3.9) + 8; const cx = box.x + box.w / 2; return ( {rows.map((words, r) => words.map((w, i) => { const b = wordBox(r, i); const hit = r === match[0] && i === match[1]; return ( ); }), )} {query} ); } function useCalmMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export function SearchScan({ query = "roadmap", title = "Search across every doc", description = "Type a word and we'll find it in your notes, specs and files.", hue = "sky", className, }: SearchScanProps) { const uid = useId().replace(/:/g, ""); const reduce = useCalmMotion(); const root = useRef(null); const inView = useInView(root); const [round, setRound] = useState(0); const [found, setFound] = useState(false); const [typed, setTyped] = useState(0); const match = matches[round % matches.length]; const target = wordBox(...match); const lx = useMotionValue(PAGE.x + 52); const ly = useMotionValue(ROW_Y0 + 10); const mx = useTransform(lx, (v) => v * (1 - ZOOM)); const my = useTransform(ly, (v) => v * (1 - ZOOM)); const lensScale = useMotionValue(1); useEffect(() => { if (reduce) { lx.set(target.x + target.w / 2); ly.set(target.y + 3); setTyped(query.length); setFound(true); return; } if (!inView) return; let alive = true; const timers: ReturnType[] = []; const stops: { stop: () => void }[] = []; const wait = (ms: number) => new Promise((r) => timers.push(setTimeout(r, ms))); const glide = async (x: number, y: number, duration: number) => { const a = animate(lx, x, { duration, ease: [0.45, 0, 0.25, 1] }); const b = animate(ly, y, { duration, ease: [0.45, 0, 0.25, 1] }); stops.push(a, b); await Promise.all([a, b]); }; (async () => { setFound(false); setTyped(0); for (let i = 1; i <= query.length; i++) { await wait(70); if (!alive) return; setTyped(i); } await wait(250); const right = PAGE.x + PAGE.w - 40; const left = PAGE.x + 44; const sweep = round % 2 === 0 ? [[right, ROW_Y0 + 8], [right, ROW_Y0 + 42], [left, ROW_Y0 + 42], [left, ROW_Y0 + 74]] : [[right, ROW_Y0 + 10], [left, ROW_Y0 + 40], [right, ROW_Y0 + 76], [left, ROW_Y0 + 96]]; for (const [x, y] of sweep) { await glide(x, y, 0.62); if (!alive) return; } await glide(target.x + target.w / 2, target.y + 3, 0.7); if (!alive) return; setFound(true); stops.push(animate(lensScale, [1, 1.08, 1], { duration: 0.42, ease: [0.23, 1, 0.32, 1] })); await wait(2400); if (!alive) return; setFound(false); await glide(PAGE.x + 52, ROW_Y0 + 10, 0.8); await wait(700); if (alive) setRound((r) => r + 1); })(); return () => { alive = false; for (const t of timers) clearTimeout(t); for (const s of stops) s.stop(); }; }, [round, reduce, inView, query.length, lx, ly, lensScale, target.x, target.y, target.w]); return (
{query.slice(0, typed)} {typed < query.length && } ยท 1 match

{title}

{description}

); }