Locality search palette
Prefix-first substring search, with a solid-vs-dashed swatch mirroring the map
npx shadcn@latest add https://ethankd.com/r/hk-command-palette.json"use client";
/**
* Locality search-and-jump. Matching is substring, not fuzzy — with a
* handful of fixed options fuzzy matching only adds surprising ranking, so
* prefix hits sort first because that's what people actually type. Each
* row's swatch is solid or dashed depending on whether that place has a
* surveyed boundary or only an approximate one, mirroring how the real map
* draws the outline. Isolated with self-managed open state rather than the
* original's page-wide ⌘K listener, which would fight every other showcase
* entry on this page.
*/
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
const PLACES = [
{ id: "koramangala", name: "Koramangala", surveyed: true },
{ id: "indiranagar", name: "Indiranagar", surveyed: true },
{ id: "whitefield", name: "Whitefield", surveyed: false },
{ id: "jayanagar", name: "Jayanagar", surveyed: true },
{ id: "hsr-layout", name: "HSR Layout", surveyed: false },
{ id: "malleswaram", name: "Malleswaram", surveyed: true },
];
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.34 };
export function CommandPalette() {
const [open, setOpen] = useState(false);
const reduced = useReducedMotion();
const [q, setQ] = useState("");
const [cursor, setCursor] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const results = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return PLACES;
return PLACES.filter((w) => w.name.toLowerCase().includes(needle)).sort((a, b) => {
const ap = a.name.toLowerCase().startsWith(needle) ? 0 : 1;
const bp = b.name.toLowerCase().startsWith(needle) ? 0 : 1;
return ap - bp || a.name.localeCompare(b.name);
});
}, [q]);
useEffect(() => {
if (!open) return;
setQ("");
setCursor(0);
const id = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(id);
}, [open]);
const close = useCallback(() => setOpen(false), []);
const choose = useCallback(() => close(), [close]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setCursor((c) => (results.length ? (c + 1) % results.length : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setCursor((c) => (results.length ? (c - 1 + results.length) % results.length : 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (results[cursor]) choose();
}
};
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="flex h-10 items-center gap-2.5 rounded-full border border-white/12 bg-white/[0.07] px-4 text-[13px] text-white/80 backdrop-blur-2xl"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<circle cx="7" cy="7" r="4.75" stroke="currentColor" strokeWidth="1.4" />
<path d="M10.5 10.5L14 14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
Search a locality…
</button>
);
}
return (
<div className="relative flex min-h-[22rem] items-start justify-center rounded-2xl bg-[#0a0a0a] px-4 pt-8">
<AnimatePresence>
<motion.div
role="dialog"
aria-modal
aria-label="Search a locality"
onKeyDown={onKeyDown}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: -12, scale: 0.97, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
transition={reduced ? { duration: 0.1 } : SPRING}
style={{ transformOrigin: "top center" }}
className="relative w-full max-w-md overflow-hidden rounded-[1.75rem] border border-white/12 bg-white/[0.07] p-1.5 shadow-[0_30px_80px_-24px_rgba(0,0,0,0.95)] backdrop-blur-2xl"
>
<div className="rounded-[calc(1.75rem-0.375rem)] bg-black/55 shadow-[inset_0_1px_1px_rgba(255,255,255,0.09)]">
<div className="flex items-center gap-2.5 px-4 pt-3.5">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" className="shrink-0 text-white/60" aria-hidden>
<circle cx="7" cy="7" r="4.75" stroke="currentColor" strokeWidth="1.4" />
<path d="M10.5 10.5L14 14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
value={q}
onChange={(e) => {
setQ(e.target.value);
setCursor(0);
}}
placeholder="Search a locality…"
className="h-10 w-full bg-transparent text-[15px] text-white outline-none placeholder:text-white/55"
/>
</div>
<div className="mt-1 max-h-[46vh] overflow-y-auto px-1.5 pb-1.5">
{results.map((w, i) => (
<button
key={w.id}
onMouseEnter={() => setCursor(i)}
onClick={choose}
aria-selected={i === cursor}
className={`flex h-12 w-full items-center gap-2.5 rounded-2xl px-2.5 text-left transition-colors duration-200 ${
i === cursor ? "bg-white/10" : "hover:bg-white/[0.06]"
}`}
>
<span
aria-hidden
className="h-3.5 w-3.5 shrink-0 rounded-[4px]"
style={{ border: `1.5px ${w.surveyed ? "solid" : "dashed"} ${w.surveyed ? "#6cb6ff" : "#93a4b8"}` }}
/>
<span className="min-w-0 flex-1 truncate text-sm text-white/90">{w.name}</span>
<span className="shrink-0 text-[11px] text-white/55">↵</span>
</button>
))}
</div>
<div className="border-t border-white/[0.07] px-4 py-2 text-[10.5px] text-white/55">
↑↓ to move · ↵ to jump · esc to close
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
);
}