{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "magnify-dock",
  "type": "registry:component",
  "title": "Magnify Dock",
  "description": "An app dock that swells under your pointer on a stiff spring, bounces apps as they launch, marks running ones with a dot, and works from the keyboard too.",
  "categories": [
    "component",
    "Workspace"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/magnify-dock.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  type MotionValue,\n  motion,\n  useAnimationControls,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DockApp {\n  id: string;\n  name: string;\n  icon: ReactNode;\n  tile?: string;\n  running?: boolean;\n  badge?: number;\n}\n\nexport interface MagnifyDockProps {\n  apps: DockApp[];\n  trailing?: DockApp[];\n  size?: number;\n  magnification?: number;\n  reach?: number;\n  onOpen?: (app: DockApp) => void;\n  className?: string;\n}\n\nconst LAUNCH_MS = 1100;\n\nexport function MagnifyDock({\n  apps,\n  trailing = [],\n  size = 40,\n  magnification = 64,\n  reach = 120,\n  onOpen,\n  className,\n}: MagnifyDockProps) {\n  const reduce = useReducedMotion();\n  const pointerX = useMotionValue(Number.POSITIVE_INFINITY);\n  const [running, setRunning] = useState(() => new Set([...apps, ...trailing].filter((a) => a.running).map((a) => a.id)));\n  const [launching, setLaunching] = useState<string | null>(null);\n  const [label, setLabel] = useState<string | null>(null);\n  const [focusIndex, setFocusIndex] = useState(0);\n  const warm = useRef(false);\n  const labelTimer = useRef<number>(undefined);\n  const launchTimer = useRef<number>(undefined);\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  useEffect(\n    () => () => {\n      clearTimeout(labelTimer.current);\n      clearTimeout(launchTimer.current);\n    },\n    [],\n  );\n\n  const all = [...apps, ...trailing];\n\n  const showLabel = (id: string) => {\n    clearTimeout(labelTimer.current);\n    if (warm.current) return setLabel(id);\n    labelTimer.current = window.setTimeout(() => {\n      warm.current = true;\n      setLabel(id);\n    }, 220);\n  };\n  const hideLabels = () => {\n    clearTimeout(labelTimer.current);\n    warm.current = false;\n    setLabel(null);\n  };\n\n  const open = (app: DockApp) => {\n    if (running.has(app.id) || reduce) {\n      setRunning((s) => new Set(s).add(app.id));\n      onOpen?.(app);\n      return;\n    }\n    if (launching) return;\n    setLaunching(app.id);\n    clearTimeout(launchTimer.current);\n    launchTimer.current = window.setTimeout(() => {\n      setLaunching(null);\n      setRunning((s) => new Set(s).add(app.id));\n      onOpen?.(app);\n    }, LAUNCH_MS);\n  };\n\n  const magnifyAround = (index: number) => {\n    const el = refs.current[index];\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    pointerX.set(r.left + r.width / 2);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const n = all.length;\n    let next = -1;\n    if (event.key === \"ArrowRight\") next = (focusIndex + 1) % n;\n    else if (event.key === \"ArrowLeft\") next = (focusIndex - 1 + n) % n;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = n - 1;\n    if (next < 0) return;\n    event.preventDefault();\n    setFocusIndex(next);\n    refs.current[next]?.focus();\n  };\n\n  const tile = (app: DockApp, index: number) => (\n    <DockTile\n      app={app}\n      focusable={index === focusIndex}\n      key={app.id}\n      label={label === app.id}\n      labelAnimated={!warm.current}\n      launching={launching === app.id}\n      magnification={reduce ? size : magnification}\n      onBlur={() => {\n        pointerX.set(Number.POSITIVE_INFINITY);\n        hideLabels();\n      }}\n      onFocus={(event) => {\n        setFocusIndex(index);\n        if (event.currentTarget.matches(\":focus-visible\")) {\n          magnifyAround(index);\n          warm.current = true;\n          setLabel(app.id);\n        }\n      }}\n      onHover={() => showLabel(app.id)}\n      onOpen={() => open(app)}\n      pointerX={pointerX}\n      reach={reach}\n      ref={(el) => {\n        refs.current[index] = el;\n      }}\n      reduce={!!reduce}\n      running={running.has(app.id)}\n      size={size}\n    />\n  );\n\n  return (\n    <div\n      aria-label=\"Dock\"\n      aria-orientation=\"horizontal\"\n      className={cn(\n        \"relative flex w-max max-w-full items-end gap-1 rounded-2xl border bg-background/70 px-1.5 pt-1.5 pb-2.5 shadow-lg/5 backdrop-blur-xl\",\n        \"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[inset_0_1px_--theme(--color-white/40%)] dark:before:shadow-[inset_0_1px_--theme(--color-white/8%)]\",\n        className,\n      )}\n      onKeyDown={onKeyDown}\n      onPointerLeave={() => {\n        pointerX.set(Number.POSITIVE_INFINITY);\n        hideLabels();\n      }}\n      onPointerMove={(event) => {\n        if (event.pointerType !== \"touch\") pointerX.set(event.clientX);\n      }}\n      role=\"toolbar\"\n      style={{ height: size + 18 }}\n    >\n      {apps.map((app, i) => tile(app, i))}\n      {trailing.length > 0 && <div aria-hidden className=\"mx-1 w-px self-stretch bg-border\" />}\n      {trailing.map((app, i) => tile(app, apps.length + i))}\n    </div>\n  );\n}\n\ninterface DockTileProps {\n  app: DockApp;\n  pointerX: MotionValue<number>;\n  size: number;\n  magnification: number;\n  reach: number;\n  running: boolean;\n  launching: boolean;\n  label: boolean;\n  labelAnimated: boolean;\n  focusable: boolean;\n  reduce: boolean;\n  onOpen: () => void;\n  onHover: () => void;\n  onFocus: (event: React.FocusEvent<HTMLButtonElement>) => void;\n  onBlur: () => void;\n  ref: (el: HTMLButtonElement | null) => void;\n}\n\nfunction DockTile({\n  app,\n  pointerX,\n  size,\n  magnification,\n  reach,\n  running,\n  launching,\n  label,\n  labelAnimated,\n  focusable,\n  reduce,\n  onOpen,\n  onHover,\n  onFocus,\n  onBlur,\n  ref,\n}: DockTileProps) {\n  const local = useRef<HTMLButtonElement | null>(null);\n  const bounce = useAnimationControls();\n\n  const distance = useTransform(pointerX, (x) => {\n    const r = local.current?.getBoundingClientRect();\n    if (!r || !Number.isFinite(x)) return reach * 2;\n    return x - (r.left + r.width / 2);\n  });\n  const target = useTransform(distance, [-reach, 0, reach], [size, magnification, size], { clamp: true });\n  const width = useSpring(target, { stiffness: 380, damping: 32, mass: 0.6 });\n\n  useEffect(() => {\n    if (!launching) return;\n    bounce.start({\n      y: [0, -size * 0.55, 0, -size * 0.3, 0],\n      transition: { duration: LAUNCH_MS / 1000, times: [0, 0.25, 0.5, 0.72, 0.92], ease: [\"easeOut\", \"easeIn\", \"easeOut\", \"easeIn\"] },\n    });\n  }, [launching, bounce, size]);\n\n  return (\n    <motion.div className=\"relative flex shrink-0 flex-col items-center\" style={{ width }}>\n      <AnimatePresence>\n        {label && !launching && (\n          <motion.span\n            animate={{ opacity: 1, y: 0 }}\n            className=\"pointer-events-none absolute bottom-full mb-2 whitespace-nowrap rounded-md border bg-popover px-2 py-0.5 font-medium text-popover-foreground text-xs shadow-md/5\"\n            exit={{ opacity: 0, transition: { duration: 0.08 } }}\n            initial={labelAnimated && !reduce ? { opacity: 0, y: 3 } : false}\n            transition={{ duration: 0.14, ease: [0.23, 1, 0.32, 1] }}\n          >\n            {app.name}\n          </motion.span>\n        )}\n      </AnimatePresence>\n      <motion.button\n        animate={bounce}\n        aria-busy={launching || undefined}\n        aria-label={`${app.name}${running ? \", open\" : \"\"}${app.badge ? `, ${app.badge} unread` : \"\"}`}\n        className={cn(\n          \"group/tile relative flex aspect-square w-full cursor-pointer items-center justify-center rounded-[22%] outline-none\",\n          \"shadow-[0_1px_2px_--theme(--color-black/12%),inset_0_1px_--theme(--color-white/25%)] transition-[filter] duration-150\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"active:brightness-90 [&_svg]:size-[52%] [&_svg]:drop-shadow-[0_1px_1px_--theme(--color-black/18%)]\",\n          app.tile ?? \"bg-muted text-foreground\",\n        )}\n        onBlur={onBlur}\n        onClick={onOpen}\n        onFocus={onFocus}\n        onPointerEnter={(event) => event.pointerType !== \"touch\" && onHover()}\n        ref={(el) => {\n          local.current = el;\n          ref(el);\n        }}\n        tabIndex={focusable ? 0 : -1}\n        type=\"button\"\n        whileTap={reduce ? undefined : { scale: 0.97 }}\n      >\n        {app.icon}\n        {app.badge ? (\n          <span className=\"absolute -top-1 -right-1 flex h-[38%] min-w-[38%] items-center justify-center rounded-full bg-destructive px-[0.3em] font-semibold text-[max(10px,22%)] text-white tabular-nums leading-none shadow-xs\">\n            {app.badge}\n          </span>\n        ) : null}\n      </motion.button>\n      <motion.span\n        animate={{ opacity: running ? 1 : 0, scale: running ? 1 : 0.5 }}\n        aria-hidden\n        className=\"absolute -bottom-2 size-1 rounded-full bg-foreground/70\"\n        initial={false}\n        transition={{ duration: 0.2, ease: \"easeOut\" }}\n      />\n    </motion.div>\n  );\n}\n"
    }
  ]
}