{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"filter-list","type":"registry:component","title":"Filter list","description":"Rows fade out of the way while the rest slide up to close the gap.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/filter-list.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type FilterItem = { name: string; category: string };\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst PILL = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Survivors slide up over 250ms while leavers are gone in 150ms, so the gap\n// is never left standing empty.\nconst SLIDE = { duration: 0.25, ease: EASE_OUT };\nconst LEAVE = { duration: 0.15, ease: EASE_OUT };\nconst ENTER = { duration: 0.2, ease: EASE_OUT };\n// Waits for the leaving rows to clear before the empty state fades in over\n// them.\nconst EMPTY_IN = { duration: 0.2, ease: EASE_OUT, delay: 0.1 };\n// A count read out mid-word is noise; wait for a pause in typing.\nconst ANNOUNCE_DELAY = 600;\n\nconst INSTANT = { duration: 0 };\n\nexport function FilterList({\n  items,\n  categories,\n  label = \"Search\",\n  placeholder = \"Search\",\n  className,\n}: {\n  items: FilterItem[];\n  categories: string[];\n  label?: string;\n  placeholder?: string;\n  className?: string;\n}) {\n  const id = useId();\n  const reduceMotion = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [query, setQuery] = useState(\"\");\n  const [category, setCategory] = useState(\"All\");\n\n  const needle = query.trim().toLowerCase();\n  const visible = items.filter(\n    (item) =>\n      (category === \"All\" || item.category === category) &&\n      item.name.toLowerCase().includes(needle),\n  );\n\n  const [announced, setAnnounced] = useState(\"\");\n  const count = visible.length;\n  // Nothing to report until the user has actually filtered.\n  const touched = query !== \"\" || category !== \"All\" || announced !== \"\";\n  useEffect(() => {\n    if (!touched) return;\n    const timer = setTimeout(\n      () => setAnnounced(count === 1 ? \"1 result\" : `${count} results`),\n      ANNOUNCE_DELAY,\n    );\n    return () => clearTimeout(timer);\n  }, [count, touched]);\n\n  const clear = () => {\n    setQuery(\"\");\n    inputRef.current?.focus();\n  };\n\n  return (\n    <div className={cn(\"flex flex-col gap-3\", className)}>\n      <div className=\"relative\">\n        <svg\n          viewBox=\"0 0 16 16\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n          className=\"pointer-events-none absolute top-[15px] left-3.5 size-[18px] text-muted-foreground\"\n        >\n          <circle cx=\"7\" cy=\"7\" r=\"4.25\" />\n          <path d=\"m10.25 10.25 3 3\" />\n        </svg>\n        <label htmlFor={`${id}-q`} className=\"sr-only\">\n          {label}\n        </label>\n        <input\n          ref={inputRef}\n          id={`${id}-q`}\n          type=\"search\"\n          autoComplete=\"off\"\n          spellCheck={false}\n          placeholder={placeholder}\n          value={query}\n          aria-controls={`${id}-list`}\n          onChange={(e) => setQuery(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.key !== \"Escape\" || query === \"\") return;\n            e.preventDefault();\n            setQuery(\"\");\n          }}\n          className=\"h-12 w-full rounded-[14px] border border-border bg-background pr-12 pl-11 text-[15px] text-foreground outline-hidden placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-1 focus-visible:outline-foreground [&::-webkit-search-cancel-button]:appearance-none\"\n        />\n        {/* 10px radius inside the 14px field with 4px of inset: concentric.\n            Kept mounted so it swaps with the icon recipe both ways. */}\n        <button\n          type=\"button\"\n          aria-label=\"Clear search\"\n          tabIndex={query === \"\" ? -1 : undefined}\n          aria-hidden={query === \"\" || undefined}\n          onClick={clear}\n          className={cn(\n            \"absolute top-1 right-1 flex size-10 touch-manipulation items-center justify-center rounded-[10px] text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out select-none hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\",\n            query === \"\" && \"pointer-events-none\",\n          )}\n        >\n          <motion.svg\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            aria-hidden\n            className=\"size-[18px]\"\n            initial={false}\n            animate={\n              query !== \"\"\n                ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n                : reduceMotion\n                  ? { scale: 1, opacity: 0, filter: \"blur(0px)\" }\n                  : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n            }\n            transition={ICON_SWAP}\n          >\n            <path d=\"m4.75 4.75 6.5 6.5M11.25 4.75l-6.5 6.5\" />\n          </motion.svg>\n        </button>\n      </div>\n\n      <div\n        role=\"group\"\n        aria-label=\"Category\"\n        className=\"flex gap-1 rounded-full bg-muted p-1\"\n      >\n        {[\"All\", ...categories].map((c) => {\n          const active = c === category;\n          return (\n            <button\n              key={c}\n              type=\"button\"\n              aria-pressed={active}\n              onClick={() => setCategory(c)}\n              className={cn(\n                \"relative h-8 flex-1 touch-manipulation rounded-full text-sm font-medium outline-hidden transition-[scale,color] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color]\",\n                active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n              )}\n            >\n              {active && (\n                <motion.span\n                  layoutId={`${id}-pill`}\n                  aria-hidden\n                  className=\"absolute inset-0 rounded-full bg-background shadow-raised\"\n                  transition={reduceMotion ? INSTANT : PILL}\n                />\n              )}\n              <span className=\"relative\">{c}</span>\n            </button>\n          );\n        })}\n      </div>\n\n      {/* Fixed height with its own scroll, so filtering never moves anything\n          on the page around it. layoutScroll lets Motion measure rows\n          correctly after the list has been scrolled. */}\n      <motion.div\n        layoutScroll\n        tabIndex={0}\n        role=\"region\"\n        aria-label=\"Filter results\"\n        className=\"relative h-72 overflow-y-auto overscroll-contain rounded-2xl border border-border p-1.5 [scrollbar-width:thin] outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-foreground\"\n      >\n        <ul id={`${id}-list`} aria-label=\"Results\" className=\"relative\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {visible.map((item) => (\n              <motion.li\n                key={item.name}\n                // Rows never change size, only place, so \"position\" skips\n                // the scale correction that would stretch the text.\n                layout=\"position\"\n                initial={\n                  reduceMotion\n                    ? { opacity: 0 }\n                    : { opacity: 0, filter: \"blur(4px)\" }\n                }\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                exit={\n                  reduceMotion\n                    ? { opacity: 0, transition: LEAVE }\n                    : { opacity: 0, scale: 0.98, transition: LEAVE }\n                }\n                transition={\n                  reduceMotion\n                    ? { ...ENTER, layout: INSTANT }\n                    : { ...ENTER, layout: SLIDE }\n                }\n                className=\"flex h-11 items-center justify-between gap-4 rounded-[10px] px-3.5\"\n              >\n                <span className=\"truncate text-[15px] text-foreground\">\n                  <Highlight text={item.name} needle={needle} />\n                </span>\n                <span className=\"shrink-0 text-[13px] text-muted-foreground\">\n                  {item.category}\n                </span>\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n\n        <AnimatePresence initial={false}>\n          {visible.length === 0 && (\n            <motion.p\n              key=\"empty\"\n              initial={{ opacity: 0, filter: \"blur(4px)\" }}\n              animate={{\n                opacity: 1,\n                filter: \"blur(0px)\",\n                transition: EMPTY_IN,\n              }}\n              exit={{ opacity: 0, transition: INSTANT }}\n              className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 px-6 text-center text-[15px] text-muted-foreground\"\n            >\n              <span className=\"text-foreground\">No matches</span>\n              <span className=\"text-[13px]\">Try another word or category.</span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </motion.div>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announced}\n      </span>\n    </div>\n  );\n}\n\nfunction Highlight({ text, needle }: { text: string; needle: string }) {\n  const at = needle ? text.toLowerCase().indexOf(needle) : -1;\n  if (at === -1) return text;\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"rounded-[4px] bg-foreground/10 text-foreground\">\n        {text.slice(at, at + needle.length)}\n      </mark>\n      {text.slice(at + needle.length)}\n    </>\n  );\n}\n\nconst COMPONENTS: FilterItem[] = [\n  { name: \"Hold to delete\", category: \"Motion\" },\n  { name: \"Elastic slider\", category: \"Input\" },\n  { name: \"Swipe deck\", category: \"Motion\" },\n  { name: \"Segmented control\", category: \"Input\" },\n  { name: \"Stacked drawer\", category: \"Layout\" },\n  { name: \"Odometer\", category: \"Motion\" },\n  { name: \"OTP input\", category: \"Input\" },\n  { name: \"Snap carousel\", category: \"Layout\" },\n  { name: \"Toast stack\", category: \"Motion\" },\n  { name: \"Kanban board\", category: \"Layout\" },\n  { name: \"Copy button\", category: \"Input\" },\n];\n\nexport default function FilterListDemo() {\n  return (\n    <FilterList\n      items={COMPONENTS}\n      categories={[\"Motion\", \"Input\", \"Layout\"]}\n      label=\"Search components\"\n      placeholder=\"Search components\"\n      className=\"w-[400px] max-w-full\"\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["data"],"docs":"From ui lab: https://lab.xevrion.dev/lab/filter-list"}