{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"marquee-select","type":"registry:ui","title":"Marquee select","description":"Select many items by dragging a rubber band over them, the way a desktop does. Chosen items lift with a tick, Shift keeps what was already chosen, and a count springs up with a way to clear.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/marquee-select.tsx","type":"registry:ui","target":"components/ui/marquee-select.tsx","content":"\"use client\";\nimport { useRef, useState, type ComponentProps, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./marquee-select.css\";\n\nexport type MarqueeItem = { id: string; label: string; content?: ReactNode };\n\nexport type MarqueeSelectProps = Omit<ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  /** Names the collection, e.g. \"Photos\". */\n  label: string;\n  items: MarqueeItem[];\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Classes for the grid of items, such as its columns. */\n  gridClassName?: string;\n};\n\ntype Box = { x: number; y: number; w: number; h: number };\nconst hit = (a: Box, b: DOMRect, origin: DOMRect) => a.x < b.right - origin.left && a.x + a.w > b.left - origin.left && a.y < b.bottom - origin.top && a.y + a.h > b.top - origin.top;\n\n/**\n * Select many items by dragging a rubber band over them, the way a desktop does. Items inside the band lift as it\n * passes, Shift keeps what was already chosen, and a click chooses one (Shift or ⌘ adds it). A count springs up with\n * a way to clear. From the keyboard: arrows move, Space chooses, Shift with an arrow extends, ⌘A or Ctrl+A takes all,\n * Escape clears. Under reduced motion the lift and count appear without travel.\n */\nexport function MarqueeSelect({ label, items, value, defaultValue = [], onValueChange, gridClassName, className, ...props }: MarqueeSelectProps) {\n  const [own, setOwn] = useState<string[]>(defaultValue);\n  const selected = value ?? own;\n  const [band, setBand] = useState<Box | null>(null);\n  const [focus, setFocus] = useState(0);\n  const root = useRef<HTMLDivElement>(null);\n  const press = useRef<{ x: number; y: number; base: string[]; moved: boolean; id: string | null; additive: boolean } | null>(null);\n\n  const set = (next: string[]) => {\n    const ordered = items.map((item) => item.id).filter((id) => next.includes(id));\n    if (value === undefined) setOwn(ordered);\n    onValueChange?.(ordered);\n  };\n  const options = () => [...(root.current?.querySelectorAll<HTMLElement>('[role=\"option\"]') ?? [])];\n\n  const down = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0 || (event.target as HTMLElement).closest(\"[data-slot='ms-count']\")) return;\n    const box = event.currentTarget.getBoundingClientRect();\n    const option = (event.target as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n    const additive = event.shiftKey || event.metaKey || event.ctrlKey;\n    press.current = { x: event.clientX - box.left, y: event.clientY - box.top, base: additive ? selected : [], moved: false, id: option?.dataset.id ?? null, additive };\n    event.currentTarget.setPointerCapture(event.pointerId);\n  };\n  const move = (event: PointerEvent<HTMLDivElement>) => {\n    const active = press.current;\n    if (!active) return;\n    const origin = event.currentTarget.getBoundingClientRect();\n    const x = event.clientX - origin.left,\n      y = event.clientY - origin.top;\n    if (!active.moved && Math.hypot(x - active.x, y - active.y) < 6) return;\n    active.moved = true;\n    const next = { x: Math.min(x, active.x), y: Math.min(y, active.y), w: Math.abs(x - active.x), h: Math.abs(y - active.y) };\n    setBand(next);\n    const hits = options().filter((el) => hit(next, el.getBoundingClientRect(), origin)).map((el) => el.dataset.id!);\n    set([...new Set([...active.base, ...hits])]);\n  };\n  const up = () => {\n    const active = press.current;\n    press.current = null;\n    setBand(null);\n    if (!active || active.moved || !active.id) {\n      if (active && !active.moved && !active.id && !active.additive) set([]); // a click on empty space clears\n      return;\n    }\n    const id = active.id;\n    if (active.additive) set(selected.includes(id) ? selected.filter((other) => other !== id) : [...selected, id]);\n    else set([id]);\n    setFocus(items.findIndex((item) => item.id === id));\n  };\n\n  const key = (event: KeyboardEvent<HTMLDivElement>) => {\n    const els = options();\n    const here = els[focus]?.getBoundingClientRect();\n    let next = focus;\n    if (event.key === \"ArrowRight\") next = Math.min(items.length - 1, focus + 1);\n    else if (event.key === \"ArrowLeft\") next = Math.max(0, focus - 1);\n    else if ((event.key === \"ArrowDown\" || event.key === \"ArrowUp\") && here) {\n      // The nearest item in the next row, by position, so any grid works.\n      const down = event.key === \"ArrowDown\";\n      const row = els.map((el, i) => ({ i, box: el.getBoundingClientRect() })).filter(({ box }) => (down ? box.top > here.bottom - 2 : box.bottom < here.top + 2));\n      const rowTop = down ? Math.min(...row.map(({ box }) => box.top)) : Math.max(...row.map(({ box }) => box.top));\n      const best = row.filter(({ box }) => box.top === rowTop).sort((a, b) => Math.abs(a.box.left - here.left) - Math.abs(b.box.left - here.left))[0];\n      if (best) next = best.i;\n    } else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = items.length - 1;\n    else if (event.key === \" \") {\n      const id = items[focus].id;\n      set(selected.includes(id) ? selected.filter((other) => other !== id) : [...selected, id]);\n    } else if (event.key.toLowerCase() === \"a\" && (event.metaKey || event.ctrlKey)) set(items.map((item) => item.id));\n    else if (event.key === \"Escape\" && selected.length) set([]);\n    else return;\n    event.preventDefault();\n    if (next !== focus) {\n      setFocus(next);\n      els[next]?.focus();\n      if (event.shiftKey && !selected.includes(items[next].id)) set([...selected, items[next].id]);\n    }\n  };\n\n  return (\n    <div data-slot=\"marquee-select\" className={cn(\"relative grid gap-3\", className)} {...props}>\n      <div\n        ref={root}\n        role=\"listbox\"\n        aria-label={label}\n        aria-multiselectable=\"true\"\n        data-slot=\"ms-area\"\n        data-banding={band ? \"\" : undefined}\n        className={cn(\"relative grid select-none\", gridClassName)}\n        onPointerDown={down}\n        onPointerMove={move}\n        onPointerUp={up}\n        onPointerCancel={up}\n        onKeyDown={key}\n      >\n        {items.map((item, i) => (\n          <div\n            key={item.id}\n            role=\"option\"\n            data-id={item.id}\n            data-slot=\"ms-item\"\n            aria-selected={selected.includes(item.id)}\n            aria-label={item.label}\n            tabIndex={i === focus ? 0 : -1}\n            onFocus={() => setFocus(i)}\n          >\n            {item.content ?? item.label}\n          </div>\n        ))}\n        {band && <span data-slot=\"ms-band\" aria-hidden=\"true\" style={{ left: band.x, top: band.y, width: band.w, height: band.h }} />}\n      </div>\n      <div data-slot=\"ms-status\" aria-live=\"polite\" className=\"flex min-h-8 items-center\">\n        {selected.length > 0 && (\n          <span data-slot=\"ms-count\">\n            <span key={selected.length} data-slot=\"ms-number\" className=\"tabular-nums\">{selected.length}</span> selected\n            <button type=\"button\" data-slot=\"ms-clear\" onClick={() => set([])}>\n              Clear\n            </button>\n          </span>\n        )}\n      </div>\n    </div>\n  );\n}\n"},{"path":"ui/marquee-select.css","type":"registry:file","target":"components/ui/marquee-select.css","content":"/* marquee-select.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Marquee select: a band on desktop; chosen items lift with a tick, and the count springs up. */\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-area\"] { gap: 10px; outline: none; }\n  @media (pointer: fine) { [data-slot=\"marquee-select\"] [data-slot=\"ms-area\"] { touch-action: none; } }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"] { position: relative; border-radius: var(--radius-control); outline: none; cursor: default; transition: translate var(--duration-normal) var(--ease-settle), box-shadow var(--duration-normal) var(--ease-out); }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"][aria-selected=\"true\"] { translate: 0 -2px; box-shadow: 0 0 0 2px var(--foreground), var(--shadow-resting); }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"]::before, [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"]::after { content: \"\"; position: absolute; top: 6px; right: 6px; z-index: 1; width: 18px; height: 18px; border-radius: 50%; scale: 0; pointer-events: none; transition: scale var(--duration-normal) var(--ease-settle); }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"]::before { background: var(--foreground); }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"]::after { background: var(--background); mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 18 18'%3E%3Cpath d='M5.5 9.2l2.3 2.3 4.7-5' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\"); }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"][aria-selected=\"true\"]::before, [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"][aria-selected=\"true\"]::after { scale: 1; }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-band\"] { position: absolute; z-index: 3; border: 1px solid color-mix(in oklab, var(--foreground) 55%, transparent); border-radius: 4px; background: color-mix(in oklab, var(--foreground) 7%, transparent); pointer-events: none; }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-count\"] { display: inline-flex; align-items: center; gap: 4px; height: 32px; padding: 0 4px 0 12px; border-radius: 999px; background: var(--foreground); color: var(--background); font-size: 13px; animation: ms-pop var(--duration-travel) var(--ease-settle); }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-number\"] { display: inline-block; animation: ms-tick var(--duration-normal) var(--ease-out); }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-clear\"] { height: 24px; margin-left: 6px; padding: 0 10px; border: 0; border-radius: 999px; background: color-mix(in oklab, var(--background) 20%, transparent); color: inherit; font-size: 12px; cursor: pointer; }\n  [data-slot=\"marquee-select\"] [data-slot=\"ms-clear\"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"marquee-select\"] :is([data-slot=\"ms-item\"], [data-slot=\"ms-count\"], [data-slot=\"ms-number\"]), [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"]::before, [data-slot=\"marquee-select\"] [data-slot=\"ms-item\"]::after { transition: none; animation: none; }\n  }\n  @keyframes ms-pop { from { opacity: 0; scale: 0.8; translate: 0 6px; } }\n  @keyframes ms-tick { from { translate: 0 -40%; opacity: 0; } }\n}\n"}],"categories":["motion"],"meta":{"tier":"free","license":"MIT","family":"browse","docs":"/docs/components/marquee-select"}}