Magnifying dock

A dock that makes room for the tool you reach for, then opens it in place.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/magnifying-dock.json

Overview

Most docks magnify and stop there. This one is a single surface that grows into the tool you choose. Sweep across it and nearby tools swell while the row parts around them, one label glides above with its letters morphing, and a soft pad rides under the selected tool. Choose a tool with content and the tray itself rises into its panel; switch tools and the panel slides in from the side you moved toward. Every moving part is a transform eased by one frame loop that sleeps when it settles: no layout and no React renders while you move.

States

lens
Tools within 2.4 tool-widths of the pointer scale up on a cosine falloff and the row parts around them; the tray widens to fit. Distances use fixed rest positions, so growth never moves the target under the pointer.
label
One label follows the nearest tool, lifting above its current height; when the tool changes, its words morph letter by letter.
select
The pad glides to the chosen tool and keeps riding its magnification as you sweep.
open
A tool with content grows the tray up into its panel: width, height and corners ease together, the title morphs, and the content fades in. Switching tools slides the new panel in from the side you moved toward while its height eases.
close
Choosing the open tool again, Escape or a click outside folds the panel back into the tray. Escape returns focus to the tool.
badge
A count on a tool rolls when it changes and folds away at zero.

Usage

usage.tsx
import { Inbox, CalendarDays, NotebookPen } from "lucide-react";import { MagnifyingDock } from "@/components/magnifying-dock";<MagnifyingDock  items={[    { id: "inbox", label: "Inbox", icon: <Inbox />, badge: 3, content: <InboxPreview /> },    { id: "calendar", label: "Calendar", title: "Today", icon: <CalendarDays />, content: <Agenda /> },    { id: "notes", label: "Notes", icon: <NotebookPen />, content: <QuickNote /> },  ]}/>

Works with

Open real views from it

Selection and the open panel can both be controlled, so the dock can follow your router or close when work elsewhere starts.

example.tsx
"use client";import { useState } from "react";import { MagnifyingDock, type DockItem } from "@/components/magnifying-dock";export function WorkspaceDock({ tools }: { tools: DockItem[] }) {  const [view, setView] = useState(tools[0]?.id);  const [open, setOpen] = useState(false);  return (    <MagnifyingDock      items={tools}      value={view}      onValueChange={setView}      open={open}      onOpenChange={setOpen}      label="Workspace"    />  );}

Props

PropTypeDescription
itemsDockItem[]Each tool: id, label, icon, and optionally content (what it opens into), title (the panel heading, defaults to label), badge (a count) and disabled.
value / defaultValuestringControlled selected tool or the initial one. Defaults to the first enabled tool.
onValueChange(id: string) => voidCalled when a different tool is chosen.
open / defaultOpenbooleanWhether the selected tool’s panel is open. Tools without content never open.
onOpenChange(open: boolean) => voidCalled when the panel opens or folds away.
sizenumberdefault 40Rest size of a tool in pixels, 32–56.
magnificationnumberdefault 1.6Peak scale under the pointer, 1–2. It eases down to 1.22 while a panel is open so tools stay clear of it.
panelWidthnumberdefault 340Width of the open panel; it never exceeds the space available.
labelstringdefault ToolsAccessible name for the toolbar.

Accessibility and motion

  • The tools form a toolbar: one tab stop, Left and Right (and Home, End) move between enabled tools, and keyboard focus magnifies like the pointer.
  • Tools with content are disclosure buttons (aria-expanded, aria-controls) for a labelled panel region that follows them in the tab order; tools without content are toggle buttons.
  • Touch skips the lens and opens tools directly. Reduced motion removes the lens and every eased move; states change in place.
  • One frame loop eases the lens, pad, label and tray, writes only transforms and the tray’s size, and stops when everything settles, so a resting dock costs nothing.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Text morph, Number roll.

Source

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

Registry item: /r/magnifying-dock.json