{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"magnifying-dock","type":"registry:component","title":"Magnifying dock","description":"A dock that makes room for the tool you reach for, then opens it in place.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json","https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/magnifying-dock.tsx","type":"registry:component","content":"// Hairline UI · magnifying-dock · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\n/* ─────────────────────────────────────────────────────────\n * MAGNIFYING DOCK: a dock that opens its tools in place\n *\n *   lens     tools near the pointer grow and the row parts around\n *            them; the tray widens with them. Transforms only,\n *            eased in one frame loop that sleeps when it settles\n *   label    one name rides above the tool under the pointer,\n *            gliding from tool to tool while its letters morph\n *   select   a soft pad glides to the chosen tool and keeps\n *            riding its magnification\n *   open     a tool with content grows the dock itself up into\n *            its panel: the tray widens and rises, the title\n *            morphs, and switching tools slides the new panel in\n *            from the side you moved toward\n *   close    choose the open tool again, press Escape or click\n *            away, and the panel folds back into the tray\n *\n * Every tool is a real button in a toolbar: arrow keys move\n * between them, and keyboard focus magnifies like the pointer.\n * ───────────────────────────────────────────────────────── */\n\nimport * as React from 'react';\nimport { NumberRoll } from './number-roll';\nimport { TextMorph } from './text-morph';\nimport { EASE_OUT, INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { useDockLens } from './use-dock-lens';\n\nexport interface DockItem {\n  id: string;\n  label: string;\n  icon: React.ReactNode;\n  /** What the dock opens into when this tool is chosen. Without it, the tool only selects. */\n  content?: React.ReactNode;\n  /** The open panel's heading; defaults to label. */\n  title?: string;\n  /** A count on the icon, e.g. unread messages; 0 folds it away. */\n  badge?: number;\n  disabled?: boolean;\n}\n\nexport interface MagnifyingDockProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  items: DockItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  label?: string;\n  /** Rest size of a tool in px, 32–56. */\n  size?: number;\n  /** Peak scale under the pointer, 1–2. */\n  magnification?: number;\n  /** Width of the open panel in px; it never exceeds the space available. */\n  panelWidth?: number;\n}\n\nconst GAP = 6;\nconst INSET = 8;\nconst LABEL_ROOM = 34;\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, Number.isFinite(n) ? n : lo));\n\nfunction Pane({ dir, reduced, leaving, onDone, children }: { dir: number; reduced: boolean; leaving?: boolean; onDone?: () => void; children: React.ReactNode }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el || reduced || !dir) {\n      if (leaving) onDone?.();\n      return;\n    }\n    const frames = leaving\n      ? [{ opacity: 1, transform: 'none', filter: 'blur(0px)' }, { opacity: 0, transform: `translateX(${-dir * 24}px)`, filter: 'blur(4px)' }]\n      : [{ opacity: 0, transform: `translateX(${dir * 24}px)`, filter: 'blur(4px)' }, { opacity: 1, transform: 'none', filter: 'blur(0px)' }];\n    const animation = el.animate(frames, { duration: leaving ? 220 : 360, delay: leaving ? 0 : 40, easing: EASE_OUT, fill: leaving ? 'forwards' : 'backwards' });\n    if (leaving) animation.onfinish = () => onDone?.();\n    return () => animation.cancel();\n    // Runs once per pane: each pane is keyed by its tool.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  return (\n    <div ref={ref} inert={leaving} aria-hidden={leaving || undefined} className={leaving ? 'pointer-events-none absolute inset-x-0 top-0' : 'relative'}>\n      {children}\n    </div>\n  );\n}\n\nexport function MagnifyingDock({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  label = 'Tools',\n  size = 40,\n  magnification = 1.6,\n  panelWidth = 340,\n  className = '',\n  ...props\n}: MagnifyingDockProps) {\n  const reduced = useInteractionReducedMotion();\n  const S = clamp(size, 32, 56);\n  const M = clamp(magnification, 1, 2);\n  const id = React.useId();\n\n  const [ownValue, setOwnValue] = React.useState(defaultValue ?? items.find((item) => !item.disabled)?.id);\n  const selectedId = value ?? ownValue;\n  const selected = Math.max(0, items.findIndex((item) => item.id === selectedId));\n  const [ownOpen, setOwnOpen] = React.useState(defaultOpen);\n  const open = (openProp ?? ownOpen) && Boolean(items[selected]?.content);\n\n  const [hovered, setHovered] = React.useState<number | null>(null);\n  const lens = useDockLens({ count: items.length, size: S, gap: GAP, inset: INSET, magnification: M, reduced, onHover: setHovered });\n\n  const root = React.useRef<HTMLDivElement>(null);\n  const content = React.useRef<HTMLDivElement>(null);\n  const [rootWidth, setRootWidth] = React.useState(0);\n  const panelW = Math.max(200, Math.min(panelWidth, rootWidth ? rootWidth - 16 : panelWidth));\n\n  // The panel shows the open tool; while folding away it keeps the last one.\n  const [shown, setShown] = React.useState(selected);\n  const [leaving, setLeaving] = React.useState<{ key: number; index: number; dir: number }[]>([]);\n  const dir = React.useRef(0);\n  const leaveKey = React.useRef(0);\n  const [wasOpen, setWasOpen] = React.useState(open);\n  if (open !== wasOpen) setWasOpen(open);\n  if (open && shown !== selected) {\n    // Switching inside an open panel slides; opening on a different tool just shows it.\n    dir.current = wasOpen ? Math.sign(selected - shown) : 0;\n    if (wasOpen && !reduced) setLeaving((l) => [...l, { key: ++leaveKey.current, index: shown, dir: dir.current }]);\n    setShown(selected);\n  }\n\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setOwnOpen(next);\n    onOpenChange?.(next);\n  };\n  const choose = (index: number) => {\n    const item = items[index];\n    if (!item || item.disabled) return;\n    if (item.id !== selectedId) {\n      if (value === undefined) setOwnValue(item.id);\n      onValueChange?.(item.id);\n    }\n    if (!item.content) {\n      if (open) setOpen(false);\n      return;\n    }\n    if (!open) dir.current = 0;\n    setOpen(!(open && item.id === selectedId));\n  };\n\n  React.useLayoutEffect(() => {\n    lens.set({ selected, open, panelWidth: panelW });\n  });\n\n  // Sizes the open panel from its content, and the panel width from the space the dock has.\n  React.useLayoutEffect(() => {\n    const el = content.current;\n    const box = root.current;\n    if (!el || !box) return;\n    const measure = () => {\n      lens.set({ panelHeight: el.offsetHeight });\n      setRootWidth((w) => (w === box.clientWidth ? w : box.clientWidth));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    observer.observe(box);\n    return () => observer.disconnect();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // Click away to fold the panel.\n  React.useEffect(() => {\n    if (!open) return;\n    const away = (event: PointerEvent) => {\n      if (!root.current?.contains(event.target as Node)) setOpen(false);\n    };\n    document.addEventListener('pointerdown', away);\n    return () => document.removeEventListener('pointerdown', away);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open]);\n\n  const enabled = items.map((item, index) => ({ item, index })).filter(({ item }) => !item.disabled);\n  const onToolKey = (event: React.KeyboardEvent, index: number) => {\n    if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;\n    event.preventDefault();\n    const at = enabled.findIndex((entry) => entry.index === index);\n    const next = event.key === 'Home' ? enabled[0] : event.key === 'End' ? enabled.at(-1) : enabled[(at + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length];\n    if (next) lens.icons.current[next.index]?.focus({ preventScroll: true });\n  };\n\n  const band = S + 2 * INSET;\n  const headroom = Math.ceil(S * (M - 1)) + LABEL_ROOM;\n  const restWidth = items.length * S + Math.max(0, items.length - 1) * GAP + 2 * INSET;\n  const shownItem = items[shown] ?? items[selected];\n  const hoverItem = hovered !== null ? items[hovered] : undefined;\n\n  return (\n    <div\n      ref={root}\n      {...props}\n      onKeyDown={(event) => {\n        props.onKeyDown?.(event);\n        if (event.key === 'Escape' && open) {\n          event.preventDefault();\n          setOpen(false);\n          lens.icons.current[selected]?.focus({ preventScroll: true });\n        }\n      }}\n      className={`relative w-full text-foreground ${className}`}\n      style={{ height: band + headroom, ...props.style }}\n    >\n      {/* The tray: one surface that widens with the lens and rises into the panel. */}\n      <div\n        ref={lens.surface}\n        aria-hidden=\"true\"\n        className=\"absolute bottom-0 left-1/2 -translate-x-1/2 border border-border bg-background\"\n        style={{ width: restWidth, height: band, borderRadius: band / 2 }}\n      />\n\n      <div\n        ref={lens.row}\n        role=\"toolbar\"\n        aria-label={label}\n        onPointerMove={lens.pointer}\n        onPointerLeave={lens.leave}\n        className=\"absolute bottom-0 left-1/2 z-10 flex -translate-x-1/2 items-end\"\n        style={{ padding: INSET, paddingTop: INSET + (open ? 0 : Math.ceil(S * (M - 1))), gap: GAP }}\n      >\n        <div ref={lens.pad} aria-hidden=\"true\" className=\"absolute left-0 origin-bottom rounded-[12px] bg-muted\" style={{ bottom: INSET, width: S, height: S }} />\n\n        {items.map((item, index) => {\n          const isSelected = index === selected;\n          const badge = Math.max(0, Math.round(item.badge ?? 0));\n          return (\n            <div key={item.id} className=\"relative shrink-0\" style={{ width: S, height: S }}>\n              <button\n                ref={(el) => {\n                  lens.icons.current[index] = el;\n                }}\n                type=\"button\"\n                aria-label={item.label}\n                {...(item.content ? { 'aria-expanded': open && isSelected, 'aria-controls': `${id}-panel` } : { 'aria-pressed': isSelected })}\n                disabled={item.disabled}\n                tabIndex={isSelected ? 0 : -1}\n                onClick={() => choose(index)}\n                onFocus={() => lens.set({ focus: index })}\n                onBlur={() => lens.set({ focus: null })}\n                onKeyDown={(event) => onToolKey(event, index)}\n                className={`cursor-pointer disabled:cursor-default absolute inset-0 flex origin-bottom items-center justify-center rounded-[12px] transition-colors duration-200 disabled:opacity-35 ${\n                  isSelected ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'\n                } ${INTERACTION_FOCUS}`}\n              >\n                <span aria-hidden=\"true\" className=\"flex size-[50%] items-center justify-center [&>svg]:size-full [&>svg]:stroke-[1.75]\">\n                  {item.icon}\n                </span>\n                {item.badge !== undefined && (\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9.5px] font-medium tabular-nums text-primary-foreground\"\n                    style={{\n                      opacity: badge ? 1 : 0,\n                      transform: badge ? 'none' : 'scale(0.4)',\n                      transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}`,\n                    }}\n                  >\n                    <NumberRoll value={badge} duration={700} />\n                  </span>\n                )}\n              </button>\n            </div>\n          );\n        })}\n\n        {/* One label, gliding between tools; its words morph as it moves. */}\n        <div\n          ref={lens.label}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 whitespace-nowrap rounded-full border border-border bg-background px-2.5 py-1 text-[11.5px] font-medium text-foreground\"\n          style={{ bottom: band + 6, opacity: 0 }}\n        >\n          <TextMorph>{hoverItem?.label ?? ''}</TextMorph>\n        </div>\n      </div>\n\n      <div\n        ref={lens.panel}\n        id={`${id}-panel`}\n        role=\"region\"\n        aria-label={shownItem?.title ?? shownItem?.label}\n        inert={!open}\n        className=\"absolute left-1/2 -translate-x-1/2 overflow-hidden\"\n        style={{ bottom: band - 1, width: panelW, height: 0, opacity: 0, visibility: 'hidden' }}\n      >\n        <div ref={content} className=\"absolute bottom-0 left-1/2 -translate-x-1/2 px-4 pb-3.5 pt-4\" style={{ width: panelW }}>\n          <p className=\"text-[13px] font-medium text-foreground\">\n            <TextMorph>{shownItem?.title ?? shownItem?.label ?? ''}</TextMorph>\n          </p>\n          <div className=\"relative mt-3\">\n            {leaving.map((l) => (\n              <Pane key={`out-${l.key}`} dir={l.dir} reduced={reduced} leaving onDone={() => setLeaving((all) => all.filter((x) => x.key !== l.key))}>\n                {items[l.index]?.content}\n              </Pane>\n            ))}\n            <Pane key={shownItem?.id} dir={dir.current} reduced={reduced}>\n              {shownItem?.content}\n            </Pane>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"},{"path":"registry/use-dock-lens.ts","type":"registry:component","content":"// Hairline UI · magnifying-dock · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\n/*\n * The dock's motion engine. One frame loop eases every moving part toward its target and\n * stops when they all settle, so a resting dock costs nothing.\n *\n *   lens     tools near the pointer scale up and the row parts around them. Distances are\n *            measured from fixed rest positions, so growth never feeds back into the pointer\n *            maths. Only transforms move: no layout while you sweep across it.\n *   marker   the selected tool's pad glides to the new tool and rides its magnification.\n *   label    one name that follows the tool under the pointer and lifts above its new height.\n *   surface  the tray widens with the lens, and grows up into the open panel.\n */\n\nexport type DockLensOptions = {\n  count: number;\n  size: number;\n  gap: number;\n  inset: number;\n  magnification: number;\n  reduced: boolean;\n  onHover: (index: number | null) => void;\n};\n\ntype Targets = { pointer: number | null; focus: number | null; selected: number; open: boolean; panelHeight: number; panelWidth: number };\n\n// Seconds to close ~63% of the gap. While the pointer sweeps the lens is all but attached to it (TRACK); entering and\n// leaving the dock, where the targets jump, ease more gently (LENS) so the tools swell and settle instead of snapping.\nconst TRACK = 0.016;\nconst LENS = 0.06;\nconst GLIDE = 0.075;\nconst SURFACE = 0.085;\n\nexport function useDockLens({ count, size, gap, inset, magnification, reduced, onHover }: DockLensOptions) {\n  const icons = React.useRef<(HTMLElement | null)[]>([]);\n  const pad = React.useRef<HTMLDivElement>(null);\n  const label = React.useRef<HTMLDivElement>(null);\n  const surface = React.useRef<HTMLDivElement>(null);\n  const panel = React.useRef<HTMLDivElement>(null);\n  const row = React.useRef<HTMLDivElement>(null);\n\n  const target = React.useRef<Targets>({ pointer: null, focus: null, selected: 0, open: false, panelHeight: 0, panelWidth: 0 });\n  const sim = React.useRef({ s: [] as number[], o: [] as number[], pad: NaN, labelX: NaN, labelLift: 0, labelOpacity: 0, open: 0, height: 0, width: 0, last: 0 });\n  const frame = React.useRef(0);\n  const hovered = React.useRef<number | null>(null);\n  const origin = React.useRef<number | null>(null);\n  const opts = React.useRef({ count, size, gap, inset, magnification, reduced, onHover });\n  opts.current = { count, size, gap, inset, magnification, reduced, onHover };\n\n  const tick = React.useCallback((now: number) => {\n    frame.current = 0;\n    const { count: n, size: S, gap: G, inset: P, magnification: M, reduced: still, onHover: hover } = opts.current;\n    const t = target.current;\n    const st = sim.current;\n    const dt = st.last ? Math.min(0.064, (now - st.last) / 1000) : 1 / 60;\n    st.last = now;\n    const ease = (tau: number) => (still ? 1 : 1 - Math.exp(-dt / tau));\n    let moving = false;\n    const approach = (from: number, to: number, k: number) => {\n      const next = Number.isNaN(from) ? to : from + (to - from) * k;\n      if (Math.abs(next - to) > 0.0005) moving = true;\n      return Math.abs(next - to) <= 0.0005 ? to : next;\n    };\n\n    const step = S + G;\n    const rest = (i: number) => P + i * step + S / 2;\n    const anchor = t.focus !== null ? rest(t.focus) : t.pointer;\n    const reach = S * 2.4;\n    const peak = still ? 1 : t.open ? Math.min(M, 1.22) : M;\n\n    // The lens: scale per tool, then the offsets that part the row around the growth.\n    let extra = 0;\n    for (let i = 0; i < n; i++) {\n      const d = anchor === null ? Infinity : Math.abs(anchor - rest(i));\n      const f = d < reach ? (1 + Math.cos((d / reach) * Math.PI)) / 2 : 0;\n      const goal = 1 + (peak - 1) * f;\n      st.s[i] = approach(st.s[i] ?? 1, goal, ease(Math.abs(goal - (st.s[i] ?? 1)) > 0.12 ? LENS : TRACK));\n      extra += S * (st.s[i] - 1);\n    }\n    let run = 0;\n    for (let i = 0; i < n; i++) {\n      const e = S * (st.s[i] - 1);\n      st.o[i] = run + e / 2 - extra / 2;\n      run += e;\n      const el = icons.current[i];\n      if (el) el.style.transform = `translate3d(${st.o[i]}px,0,0) scale(${st.s[i]})`;\n    }\n\n    // The pad glides between rest positions and borrows the magnification of the tools it passes.\n    st.pad = approach(st.pad, rest(t.selected), ease(GLIDE));\n    const u = Math.max(0, Math.min(n - 1, (st.pad - rest(0)) / step));\n    const i0 = Math.floor(u), i1 = Math.min(n - 1, i0 + 1), w = u - i0;\n    const padOffset = (st.o[i0] ?? 0) * (1 - w) + (st.o[i1] ?? 0) * w;\n    const padScale = (st.s[i0] ?? 1) * (1 - w) + (st.s[i1] ?? 1) * w;\n    if (pad.current) pad.current.style.transform = `translate3d(${st.pad - S / 2 + padOffset}px,0,0) scale(${padScale})`;\n\n    // The label: the tool nearest the pointer, lifted above its current height.\n    const near = anchor === null ? null : Math.max(0, Math.min(n - 1, Math.round((anchor - rest(0)) / step)));\n    if (near !== hovered.current && near !== null) hover(near);\n    if (near !== null) hovered.current = near;\n    const shown = near ?? hovered.current;\n    if (shown !== null) {\n      st.labelX = approach(st.labelX, rest(shown) + (st.o[shown] ?? 0), ease(GLIDE * 0.8));\n      st.labelLift = approach(st.labelLift, S * ((st.s[shown] ?? 1) - 1), ease(TRACK * 2));\n    }\n    st.labelOpacity = approach(st.labelOpacity, near !== null && !t.open ? 1 : 0, ease(near !== null ? 0.06 : 0.05));\n    if (label.current) {\n      label.current.style.transform = `translate3d(${st.labelX}px,${-st.labelLift - (1 - st.labelOpacity) * 4}px,0) translateX(-50%)`;\n      label.current.style.opacity = String(st.labelOpacity);\n    }\n\n    // The surface: a tray that widens with the lens and grows up into the panel.\n    st.open = approach(st.open, t.open ? 1 : 0, ease(SURFACE));\n    st.height = approach(st.height, t.open ? t.panelHeight : 0, ease(SURFACE));\n    const restWidth = n * S + (n - 1) * G + 2 * P;\n    st.width = approach(st.width, t.open ? Math.max(restWidth, t.panelWidth) : restWidth, ease(SURFACE));\n    const band = S + 2 * P;\n    const wide = Math.max(st.width, restWidth + extra);\n    if (surface.current) {\n      surface.current.style.width = `${wide}px`;\n      surface.current.style.height = `${band + st.height}px`;\n      surface.current.style.borderRadius = `${band / 2 + (20 - band / 2) * Math.min(1, st.open * 1.6)}px`;\n    }\n    if (panel.current) {\n      panel.current.style.height = `${st.height}px`;\n      panel.current.style.width = `${wide - 2}px`;\n      panel.current.style.opacity = String(Math.max(0, (st.open - 0.35) / 0.65));\n      panel.current.style.visibility = st.open < 0.01 && !t.open ? 'hidden' : 'visible';\n    }\n\n    if (moving) frame.current = requestAnimationFrame(tick);\n    else st.last = 0;\n  }, []);\n\n  const kick = React.useCallback(() => {\n    if (!frame.current) frame.current = requestAnimationFrame(tick);\n  }, [tick]);\n\n  // A rAF id kept in a ref must be cleared on unmount, or StrictMode's remount thinks the loop is still running.\n  React.useEffect(() => {\n    // A scroll under a resting pointer moves the row: measure again on the next move.\n    const forget = () => { origin.current = null; };\n    window.addEventListener('scroll', forget, { passive: true, capture: true });\n    window.addEventListener('resize', forget);\n    return () => {\n      window.removeEventListener('scroll', forget, { capture: true });\n      window.removeEventListener('resize', forget);\n      cancelAnimationFrame(frame.current);\n      frame.current = 0;\n    };\n  }, []);\n  React.useLayoutEffect(() => {\n    sim.current.last = 0;\n    tick(performance.now());\n  }, [count, size, gap, inset, magnification, reduced, tick]);\n\n  return {\n    icons, pad, label, surface, panel, row,\n    set(next: Partial<Targets>) {\n      Object.assign(target.current, next);\n      kick();\n    },\n    pointer(event: React.PointerEvent) {\n      if (event.pointerType === 'touch' || !row.current) return;\n      // The row never moves under the lens (only transforms do), so its edge is read once per hover.\n      if (origin.current === null) origin.current = row.current.getBoundingClientRect().left;\n      target.current.focus = null;\n      target.current.pointer = event.clientX - origin.current;\n      kick();\n    },\n    leave() {\n      origin.current = null;\n      target.current.pointer = null;\n      kick();\n    },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · magnifying-dock · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/magnifying-dock. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}