{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transfer-list",
  "type": "registry:ui",
  "title": "Transfer List",
  "description": "Two filterable lists where checked rows fly across and land highlighted.",
  "categories": [
    "pickers"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/transfer-list.tsx",
      "content": "\"use client\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight, Search } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type TransferItem = {\n  value: string;\n  label: string;\n  description?: string;\n  /** Can't be checked or moved, like the owner of a channel. */\n  disabled?: boolean;\n  /** An avatar or icon before the label. */\n  leading?: React.ReactNode;\n};\n\ntype Side = \"source\" | \"target\";\nconst fold = (s: string) => s.normalize(\"NFD\").replace(/\\p{Diacritic}/gu, \"\").toLowerCase();\n\nexport type TransferListProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  items: TransferItem[];\n  /** Values on the right-hand (chosen) side. */\n  value?: string[];\n  defaultValue?: string[];\n  onValueChange?: (value: string[]) => void;\n  /** Headings for the two lists. */\n  titles?: [string, string];\n  /** Most items the chosen side can hold. */\n  max?: number;\n  searchPlaceholder?: string;\n  /** What an empty list says, per side. */\n  empty?: [string, string];\n  disabled?: boolean;\n};\n\nexport function TransferList({\n  items,\n  value: valueProp,\n  defaultValue = [],\n  onValueChange,\n  titles = [\"Available\", \"Selected\"],\n  max,\n  searchPlaceholder = \"Filter\",\n  empty = [\"Nothing left to add\", \"Nothing selected yet\"],\n  disabled = false,\n  className,\n  ...rest\n}: TransferListProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [checked, setChecked] = useState<Record<Side, string[]>>({ source: [], target: [] });\n  const [query, setQuery] = useState<Record<Side, string>>({ source: \"\", target: \"\" });\n  const [active, setActive] = useState<Record<Side, string | null>>({ source: null, target: null });\n  const anchor = useRef<Record<Side, string | null>>({ source: null, target: null });\n  // Layout flights only for moves made with the pointer; filtering and keys never animate position.\n  const [flying, setFlying] = useState(false);\n  const [landed, setLanded] = useState<{ id: number; values: string[] }>({ id: 0, values: [] });\n  const [announce, setAnnounce] = useState(\"\");\n  const scrollTarget = useRef<string | null>(null);\n  // The active-row outline is for keyboard users; a click focuses the list without it.\n  const [keyboard, setKeyboard] = useState(false);\n  const pointing = useRef(false);\n\n  const chosen = useMemo(() => new Set(value), [value]);\n  // Both sides keep the items' original order, so a moved item lands where you'd look for it.\n  const lists: Record<Side, TransferItem[]> = useMemo(\n    () => ({ source: items.filter((i) => !chosen.has(i.value)), target: items.filter((i) => chosen.has(i.value)) }),\n    [items, chosen],\n  );\n  const visible = (side: Side) => {\n    const q = fold(query[side].trim());\n    return q ? lists[side].filter((i) => fold(`${i.label} ${i.description ?? \"\"}`).includes(q)) : lists[side];\n  };\n  const shown: Record<Side, TransferItem[]> = { source: visible(\"source\"), target: visible(\"target\") };\n  const room = max === undefined ? Infinity : Math.max(0, max - value.length);\n  // Moves act on what you can see: checks hidden by a filter wait until the filter clears.\n  const ready = (side: Side) => shown[side].filter((i) => !i.disabled && checked[side].includes(i.value)).map((i) => i.value);\n\n  useLayoutEffect(() => {\n    if (!scrollTarget.current) return;\n    document.getElementById(scrollTarget.current)?.scrollIntoView({ block: \"nearest\" });\n    scrollTarget.current = null;\n  });\n\n  const rowId = (side: Side, v: string) => `${uid}-${side}-${v}`;\n\n  const move = (from: Side, values: string[], how: \"pointer\" | \"keyboard\") => {\n    let moving = values.filter((v) => !items.find((i) => i.value === v)?.disabled);\n    if (from === \"source\") moving = moving.slice(0, room === Infinity ? undefined : room);\n    if (!moving.length) return;\n    const set = new Set(moving);\n    const next = from === \"source\" ? items.filter((i) => chosen.has(i.value) || set.has(i.value)).map((i) => i.value) : value.filter((v) => !set.has(v));\n    setFlying(how === \"pointer\" && !reduce);\n    setLanded((l) => ({ id: l.id + 1, values: moving }));\n    setChecked((c) => ({ ...c, [from]: c[from].filter((v) => !set.has(v)) }));\n    setActive((a) => ({ ...a, [from]: null }));\n    setValue(next);\n    const to = titles[from === \"source\" ? 1 : 0];\n    setAnnounce(`Moved ${moving.length === 1 ? items.find((i) => i.value === moving[0])?.label : `${moving.length} items`} to ${to}`);\n  };\n\n  const toggle = (side: Side, v: string, on?: boolean) =>\n    setChecked((c) => {\n      const has = c[side].includes(v);\n      const want = on ?? !has;\n      return { ...c, [side]: want ? (has ? c[side] : [...c[side], v]) : c[side].filter((x) => x !== v) };\n    });\n\n  const range = (side: Side, to: string) => {\n    const list = shown[side];\n    const a = list.findIndex((i) => i.value === (anchor.current[side] ?? to));\n    const b = list.findIndex((i) => i.value === to);\n    const [lo, hi] = a < b ? [a, b] : [b, a];\n    const span = list.slice(lo, hi + 1).filter((i) => !i.disabled).map((i) => i.value);\n    setChecked((c) => ({ ...c, [side]: [...new Set([...c[side], ...span])] }));\n  };\n\n  const onListKey = (e: React.KeyboardEvent, side: Side) => {\n    const list = shown[side];\n    if (!list.length) return;\n    const i = list.findIndex((it) => it.value === active[side]);\n    const go = (n: number) => {\n      const target = list[Math.max(0, Math.min(list.length - 1, n))];\n      setActive((a) => ({ ...a, [side]: target.value }));\n      scrollTarget.current = rowId(side, target.value);\n      if (e.shiftKey) range(side, target.value);\n      else anchor.current[side] = target.value;\n    };\n    const mod = e.metaKey || e.ctrlKey;\n    if (e.key === \"ArrowDown\") (e.preventDefault(), go(i + 1));\n    else if (e.key === \"ArrowUp\") (e.preventDefault(), go(i < 0 ? 0 : i - 1));\n    else if (e.key === \"Home\") (e.preventDefault(), go(0));\n    else if (e.key === \"End\") (e.preventDefault(), go(list.length - 1));\n    else if (e.key === \" \" && active[side]) {\n      e.preventDefault();\n      const it = list[i];\n      if (it && !it.disabled) toggle(side, it.value);\n      anchor.current[side] = active[side];\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      const picks = ready(side).length ? ready(side) : active[side] ? [active[side]!] : [];\n      move(side, picks, \"keyboard\");\n    } else if (mod && e.key.toLowerCase() === \"a\") {\n      e.preventDefault();\n      setChecked((c) => ({ ...c, [side]: list.filter((it) => !it.disabled).map((it) => it.value) }));\n    } else if (e.key === \"Escape\" && checked[side].length) {\n      e.preventDefault();\n      setChecked((c) => ({ ...c, [side]: [] }));\n    }\n  };\n\n  const panel = (side: Side) => {\n    const list = shown[side];\n    const selectable = list.filter((i) => !i.disabled);\n    const n = selectable.filter((i) => checked[side].includes(i.value)).length;\n    const all = selectable.length > 0 && n === selectable.length;\n    const title = titles[side === \"source\" ? 0 : 1];\n    const q = query[side].trim();\n    const titleId = `${uid}-${side}-title`;\n    return (\n      <section aria-labelledby={titleId} className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]\">\n        <header className=\"flex h-10 shrink-0 items-center gap-2.5 border-b border-line pl-3 pr-3\">\n          <Checkbox.Root\n            checked={all}\n            indeterminate={n > 0 && !all}\n            disabled={disabled || selectable.length === 0}\n            onCheckedChange={(on) => setChecked((c) => ({ ...c, [side]: on ? selectable.map((i) => i.value) : [] }))}\n            aria-label={`Select all ${q ? \"matching \" : \"\"}in ${title}`}\n            className={cn(box, \"data-[disabled]:opacity-40\")}\n          >\n            <Checkbox.Indicator keepMounted className=\"grid place-items-center text-frame\">\n              <Tick checked={all} mixed={n > 0 && !all} reduce={!!reduce} />\n            </Checkbox.Indicator>\n          </Checkbox.Root>\n          <h3 id={titleId} className=\"min-w-0 flex-1 truncate text-[13px] font-medium tracking-[-0.01em] text-fg\">\n            {title}\n          </h3>\n          <span className=\"flex shrink-0 items-baseline gap-1 text-[12px] tabular text-fg-3\" aria-hidden>\n            {n > 0 && (\n              <>\n                <NumberFlow value={n} className=\"text-fg-2\" />\n                <span>of</span>\n              </>\n            )}\n            <NumberFlow value={lists[side].length} />\n            {side === \"target\" && max !== undefined && <span className=\"text-fg-4\">/ {max}</span>}\n          </span>\n        </header>\n\n        <label className=\"flex h-9 shrink-0 items-center gap-2 border-b border-line px-3\">\n          <Search size={14} className=\"shrink-0 text-fg-4\" />\n          <span className=\"sr-only\">{`${searchPlaceholder} ${title}`}</span>\n          <input\n            value={query[side]}\n            disabled={disabled}\n            onChange={(e) => {\n              setFlying(false);\n              setQuery((qq) => ({ ...qq, [side]: e.target.value }));\n            }}\n            onKeyDown={(e) => {\n              if (e.key === \"ArrowDown\") {\n                e.preventDefault();\n                document.getElementById(`${uid}-${side}-list`)?.focus();\n                if (!active[side] && list[0]) setActive((a) => ({ ...a, [side]: list[0].value }));\n              } else if (e.key === \"Escape\" && query[side]) {\n                e.preventDefault();\n                setQuery((qq) => ({ ...qq, [side]: \"\" }));\n              }\n            }}\n            placeholder={searchPlaceholder}\n            autoComplete=\"off\"\n            spellCheck={false}\n            className=\"h-full min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[12.5px]\"\n          />\n        </label>\n\n        <motion.ul\n          layoutScroll\n          id={`${uid}-${side}-list`}\n          role=\"listbox\"\n          aria-multiselectable\n          aria-labelledby={titleId}\n          aria-activedescendant={active[side] && list.some((i) => i.value === active[side]) ? rowId(side, active[side]!) : undefined}\n          tabIndex={disabled || list.length === 0 ? -1 : 0}\n          data-keyboard={keyboard || undefined}\n          onKeyDown={(e) => {\n            setKeyboard(true);\n            onListKey(e, side);\n          }}\n          onPointerDown={() => {\n            pointing.current = true;\n            setKeyboard(false);\n          }}\n          onFocus={() => {\n            setKeyboard(!pointing.current);\n            pointing.current = false;\n            if (!active[side] && list[0]) setActive((a) => ({ ...a, [side]: list[0].value }));\n          }}\n          className=\"group/list relative flex h-[168px] flex-col gap-0.5 overflow-y-auto @[500px]:h-[220px] overscroll-contain p-1 outline-none\"\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {list.map((it) => {\n              const isChecked = checked[side].includes(it.value);\n              const isActive = active[side] === it.value;\n              const justLanded = landed.values.includes(it.value);\n              return (\n                <motion.li\n                  key={it.value}\n                  id={rowId(side, it.value)}\n                  role=\"option\"\n                  aria-selected={isChecked}\n                  aria-disabled={it.disabled || undefined}\n                  data-active={isActive || undefined}\n                  data-checked={isChecked || undefined}\n                  layout={flying ? \"position\" : false}\n                  layoutId={flying ? `${uid}-${it.value}` : undefined}\n                  initial={flying ? false : { opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={flying ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: { duration: 0.1 } }}\n                  transition={flying ? spring.soft : { duration: 0.12 }}\n                  onPointerDown={(e) => e.preventDefault()}\n                  onClick={(e) => {\n                    document.getElementById(`${uid}-${side}-list`)?.focus({ preventScroll: true });\n                    setActive((a) => ({ ...a, [side]: it.value }));\n                    if (it.disabled) return;\n                    if (e.shiftKey && anchor.current[side]) range(side, it.value);\n                    else {\n                      toggle(side, it.value);\n                      anchor.current[side] = it.value;\n                    }\n                  }}\n                  onDoubleClick={() => !it.disabled && move(side, [it.value], \"pointer\")}\n                  className={cn(\n                    \"relative flex h-10 shrink-0 cursor-default select-none items-center gap-2.5 rounded-lg pl-2 pr-2 outline-none\",\n                    \"transition-colors duration-100 hover:bg-fg/[0.04] data-[checked]:bg-fg/[0.06]\",\n                    \"group-focus/list:group-data-[keyboard]/list:data-[active]:outline-solid group-focus/list:group-data-[keyboard]/list:data-[active]:outline-1 group-focus/list:group-data-[keyboard]/list:data-[active]:-outline-offset-1 group-focus/list:group-data-[keyboard]/list:data-[active]:outline-fg-4\",\n                    it.disabled && \"text-fg-4\",\n                  )}\n                >\n                  {justLanded && (\n                    // Where it landed: a wash that fades once the flight settles.\n                    <motion.span\n                      key={landed.id}\n                      aria-hidden\n                      className=\"pointer-events-none absolute inset-0 rounded-lg bg-fg/[0.08]\"\n                      initial={{ opacity: 1 }}\n                      animate={{ opacity: 0 }}\n                      transition={{ duration: 0.9, delay: 0.3, ease: ease.outQuart }}\n                    />\n                  )}\n                  <span aria-hidden data-checked={isChecked || undefined} className={cn(box, \"relative\", it.disabled && \"opacity-40\")}>\n                    <span className=\"grid place-items-center text-frame\">\n                      <Tick checked={isChecked} reduce={!!reduce} />\n                    </span>\n                  </span>\n                  {it.leading && <span className=\"relative shrink-0\">{it.leading}</span>}\n                  <span className=\"relative flex min-w-0 flex-1 flex-col\">\n                    <span className={cn(\"truncate text-[13px] leading-[1.3]\", it.disabled ? \"text-fg-3\" : \"text-fg\")}>{it.label}</span>\n                    {it.description && <span className=\"truncate text-[11.5px] leading-[1.3] text-fg-3\">{it.description}</span>}\n                  </span>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n          {list.length === 0 && (\n            <li role=\"presentation\" className=\"flex flex-1 flex-col items-center justify-center gap-1 px-4 text-center\">\n              <span className=\"text-[12.5px] text-fg-2\">{q ? `No matches for “${q}”` : empty[side === \"source\" ? 0 : 1]}</span>\n              {q ? (\n                <button\n                  type=\"button\"\n                  onClick={() => setQuery((qq) => ({ ...qq, [side]: \"\" }))}\n                  className=\"mt-1 h-7 rounded-md border border-line-2 px-2.5 text-[12px] font-medium text-fg outline-none transition-[background-color,scale] duration-150 hover:bg-fg/5 active:scale-[0.97] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n                >\n                  Clear filter\n                </button>\n              ) : side === \"target\" ? (\n                <span className=\"text-[12px] text-fg-3\">Check people on the left, then move them across.</span>\n              ) : null}\n            </li>\n          )}\n        </motion.ul>\n      </section>\n    );\n  };\n\n  const nIn = ready(\"source\").length;\n  const nOut = ready(\"target\").length;\n  const addable = Math.min(nIn, room);\n  const allIn = shown.source.filter((i) => !i.disabled).length;\n  const allOut = shown.target.filter((i) => !i.disabled).length;\n\n  return (\n    <div data-disabled={disabled || undefined} className={cn(\"@container w-full data-[disabled]:pointer-events-none data-[disabled]:opacity-50\", className)} {...rest}>\n      <LayoutGroup id={uid}>\n        <div className=\"flex flex-col gap-2 @[500px]:flex-row @[500px]:items-stretch\">\n          {panel(\"source\")}\n          <div role=\"group\" aria-label=\"Move items\" className=\"flex shrink-0 items-center justify-center gap-1.5 @[500px]:flex-col\">\n            <MoveButton label={addable ? `Move ${addable} to ${titles[1]}` : `Move checked to ${titles[1]}`} disabled={!addable || disabled} onClick={() => move(\"source\", ready(\"source\"), \"pointer\")} count={addable}>\n              <ChevronRight size={16} className=\"rotate-90 @[500px]:rotate-0\" />\n            </MoveButton>\n            <MoveButton label={nOut ? `Move ${nOut} to ${titles[0]}` : `Move checked to ${titles[0]}`} disabled={!nOut || disabled} onClick={() => move(\"target\", ready(\"target\"), \"pointer\")} count={nOut}>\n              <ChevronLeft size={16} className=\"rotate-90 @[500px]:rotate-0\" />\n            </MoveButton>\n            <span aria-hidden className=\"mx-1 h-3 w-px bg-line @[500px]:mx-0 @[500px]:my-1 @[500px]:h-px @[500px]:w-3\" />\n            <MoveButton label={`Move all to ${titles[1]}`} disabled={!allIn || !room || disabled} onClick={() => move(\"source\", shown.source.map((i) => i.value), \"pointer\")} quiet>\n              <DoubleChevron className=\"rotate-90 @[500px]:rotate-0\" />\n            </MoveButton>\n            <MoveButton label={`Move all to ${titles[0]}`} disabled={!allOut || disabled} onClick={() => move(\"target\", shown.target.map((i) => i.value), \"pointer\")} quiet>\n              <DoubleChevron className=\"-rotate-90 @[500px]:rotate-180\" />\n            </MoveButton>\n          </div>\n          {panel(\"target\")}\n        </div>\n      </LayoutGroup>\n      {max !== undefined && nIn > room && (\n        <p className=\"mt-2 text-[12px] text-fg-3\">\n          {room === 0\n            ? `${titles[1]} is full at ${max}. Move someone back to make room.`\n            : `${titles[1]} holds ${max}, so only the first ${room} checked will move.`}\n        </p>\n      )}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\nconst box =\n  \"grid size-4 shrink-0 place-items-center rounded-[5px] border border-line-2 bg-transparent outline-none transition-[background-color,border-color] duration-150 data-[checked]:border-fg data-[checked]:bg-fg data-[indeterminate]:border-fg data-[indeterminate]:bg-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\";\n\nfunction Tick({ checked, mixed, reduce }: { checked: boolean; mixed?: boolean; reduce: boolean }) {\n  return (\n    <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      {mixed ? (\n        <path d=\"M4 8h8\" />\n      ) : (\n        <motion.path\n          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n          initial={false}\n          animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { pathLength: { duration: 0.22, ease: ease.out }, opacity: { duration: 0.05 } }}\n        />\n      )}\n    </svg>\n  );\n}\n\nfunction DoubleChevron({ className }: { className?: string }) {\n  return (\n    <svg width={16} height={16} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className={className}>\n      <path d=\"m4 4.5 3.5 3.5L4 11.5M8.5 4.5 12 8l-3.5 3.5\" />\n    </svg>\n  );\n}\n\nfunction MoveButton({ label, disabled, onClick, children, count, quiet }: { label: string; disabled: boolean; onClick: () => void; children: React.ReactNode; count?: number; quiet?: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      disabled={disabled}\n      onClick={onClick}\n      className={cn(\n        \"relative grid size-8 place-items-center rounded-lg outline-none transition-[background-color,border-color,color,opacity,scale] duration-150 active:scale-[0.92] disabled:pointer-events-none disabled:opacity-35\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        quiet ? \"text-fg-3 hover:bg-fg/5 hover:text-fg\" : \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4\",\n      )}\n    >\n      {children}\n      <AnimatePresence>\n        {!!count && (\n          <motion.span\n            aria-hidden\n            className=\"absolute -right-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-fg px-1 text-[10px] font-medium tabular text-frame\"\n            initial={{ opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}\n            transition={spring.pop}\n          >\n            <NumberFlow value={count} />\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/transfer-list"
}
