{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "zoom-control",
  "type": "registry:ui",
  "title": "Zoom Control",
  "description": "Minus, rolling percentage and plus, with a type-or-pick menu and pinch zoom.",
  "categories": [
    "sliders"
  ],
  "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/zoom-control.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport NumberFlow from \"@number-flow/react\";\nimport { LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronDown, Minus, Plus } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/** Where a zoom change came from, so a canvas can animate buttons but follow pinches 1:1. */\nexport type ZoomChangeReason = \"button\" | \"preset\" | \"input\" | \"shortcut\" | \"wheel\";\n\n// The ladder the buttons walk, the same stops browsers use.\nconst LADDER = [0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4];\nconst ROLL = { duration: 380, easing: `cubic-bezier(${ease.out.join(\",\")})` };\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\nconst pct = (v: number) => Math.round(v * 100);\nconst round = (v: number) => Math.round(v * 100) / 100;\n\n// ⌘ on Apple platforms, Ctrl elsewhere. The server renders Ctrl; the client corrects it without a mismatch.\nconst subscribe = () => () => {};\nconst useModKey = () =>\n  useSyncExternalStore(\n    subscribe,\n    () => (/Mac|iPhone|iPad/.test(navigator.platform) ? \"⌘\" : \"Ctrl \"),\n    () => \"Ctrl \",\n  );\n\nfunction nextStop(value: number, dir: 1 | -1, ladder: number[]) {\n  const eps = 0.001;\n  return dir > 0 ? ladder.find((s) => s > value + eps) : [...ladder].reverse().find((s) => s < value - eps);\n}\n\nexport type ZoomControlProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  /** Scale, where 1 is 100%. */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number, reason: ZoomChangeReason) => void;\n  min?: number;\n  max?: number;\n  /** Stops the minus and plus buttons walk through. */\n  steps?: number[];\n  /** Quick picks in the menu. */\n  presets?: number[];\n  /** The scale that fits your content; adds \"Zoom to fit\" (Shift+1) when set. */\n  fitValue?: number;\n  /** The canvas. Ctrl/⌘ + wheel and trackpad pinch over it zoom, and ⌘+ ⌘− ⌘0 Shift+1 work while focus is inside it. */\n  target?: React.RefObject<HTMLElement | null>;\n  size?: \"sm\" | \"md\";\n  /** Which side the menu opens on. Use \"top\" for a control docked at the bottom of a canvas. */\n  side?: \"top\" | \"bottom\";\n  disabled?: boolean;\n};\n\ntype Option = { key: string; label: string; hint?: string; value: number; selected?: boolean };\n\nexport function ZoomControl({\n  value: valueProp,\n  defaultValue = 1,\n  onValueChange,\n  min = 0.1,\n  max = 4,\n  steps = LADDER,\n  presets = [0.5, 1, 2],\n  fitValue,\n  target,\n  size = \"md\",\n  side = \"bottom\",\n  disabled = false,\n  className,\n  ...rest\n}: ZoomControlProps) {\n  const reason = useRef<ZoomChangeReason>(\"button\");\n  const [value, setInner] = useControllableState({\n    value: valueProp,\n    defaultValue,\n    onChange: (v: number) => onValueChange?.(v, reason.current),\n  });\n  const set = (v: number, why: ZoomChangeReason) => {\n    reason.current = why;\n    setInner(clamp(round(v), min, max));\n  };\n\n  const mod = useModKey();\n  const reduce = useReducedMotion();\n  const ladder = steps.filter((s) => s >= min && s <= max);\n  const canOut = !disabled && value > min + 0.001;\n  const canIn = !disabled && value < max - 0.001;\n  const zoomBy = (dir: 1 | -1, why: ZoomChangeReason) => {\n    const stop = nextStop(value, dir, ladder);\n    set(stop ?? (dir > 0 ? max : min), why);\n  };\n\n  // Pinches and Ctrl+wheel arrive as a stream; the readout stops rolling while\n  // they last so the digits keep up with the hand.\n  const [streaming, setStreaming] = useState(false);\n  const live = useRef({ value, set, zoomBy, fitValue, disabled });\n  useEffect(() => {\n    live.current = { value, set, zoomBy, fitValue, disabled };\n  });\n\n  useEffect(() => {\n    const el = target?.current;\n    if (!el) return;\n    let timer = 0;\n    const onWheel = (e: WheelEvent) => {\n      if (!(e.ctrlKey || e.metaKey) || live.current.disabled) return;\n      e.preventDefault();\n      // Trackpad pinches send small deltas, mouse notches ±100; clamping keeps a notch to ~28%.\n      const d = clamp(e.deltaY, -25, 25);\n      live.current.set(live.current.value * Math.exp(-d * 0.01), \"wheel\");\n      setStreaming(true);\n      window.clearTimeout(timer);\n      timer = window.setTimeout(() => setStreaming(false), 220);\n    };\n    const onKey = (e: KeyboardEvent) => {\n      const l = live.current;\n      if (l.disabled) return;\n      const cmd = e.metaKey || e.ctrlKey;\n      if (cmd && (e.key === \"=\" || e.key === \"+\")) l.zoomBy(1, \"shortcut\");\n      else if (cmd && e.key === \"-\") l.zoomBy(-1, \"shortcut\");\n      else if (cmd && e.key === \"0\") l.set(1, \"shortcut\");\n      else if (e.shiftKey && !cmd && e.code === \"Digit1\" && l.fitValue !== undefined) {\n        if (e.target instanceof HTMLElement && e.target.closest(\"input, textarea, [contenteditable=true]\")) return;\n        l.set(l.fitValue, \"shortcut\");\n      } else return;\n      e.preventDefault();\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    el.addEventListener(\"keydown\", onKey);\n    return () => {\n      el.removeEventListener(\"wheel\", onWheel);\n      el.removeEventListener(\"keydown\", onKey);\n      window.clearTimeout(timer);\n    };\n  }, [target]);\n\n  // Hold minus or plus to keep zooming: one step on press, then a step every 90ms after 400ms.\n  const repeat = useRef<{ t: number; i: number }>({ t: 0, i: 0 });\n  const stopRepeat = () => {\n    window.clearTimeout(repeat.current.t);\n    window.clearInterval(repeat.current.i);\n  };\n  useEffect(() => stopRepeat, []);\n  const pressHandlers = (dir: 1 | -1, enabled: boolean) => ({\n    onPointerDown: (e: React.PointerEvent) => {\n      if (!enabled || e.button !== 0) return;\n      live.current.zoomBy(dir, \"button\");\n      stopRepeat();\n      repeat.current.t = window.setTimeout(() => {\n        repeat.current.i = window.setInterval(() => live.current.zoomBy(dir, \"button\"), 90);\n      }, 400);\n    },\n    onPointerUp: stopRepeat,\n    onPointerLeave: stopRepeat,\n    onPointerCancel: stopRepeat,\n    // Enter and Space arrive as a click with no pointer (detail 0).\n    onClick: (e: React.MouseEvent) => {\n      if (enabled && e.detail === 0) zoomBy(dir, \"button\");\n    },\n  });\n\n  // Stop repeating the moment a bound is reached.\n  useEffect(() => {\n    if (value <= min + 0.001 || value >= max - 0.001) stopRepeat();\n  }, [value, min, max]);\n\n  const [open, setOpen] = useState(false);\n  const [text, setText] = useState(\"\");\n  const [error, setError] = useState(false);\n  const [active, setActive] = useState(-1);\n  const [via, setVia] = useState<\"pointer\" | \"keyboard\">(\"pointer\");\n  const inputRef = useRef<HTMLInputElement>(null);\n  const uid = useId();\n  const listId = `${uid}-list`;\n  const errorId = `${uid}-error`;\n\n  const options: Option[] = [\n    { key: \"in\", label: \"Zoom in\", hint: `${mod}+`, value: nextStop(value, 1, ladder) ?? max },\n    { key: \"out\", label: \"Zoom out\", hint: `${mod}−`, value: nextStop(value, -1, ladder) ?? min },\n    ...(fitValue !== undefined ? [{ key: \"fit\", label: \"Zoom to fit\", hint: \"⇧1\", value: fitValue, selected: pct(fitValue) === pct(value) }] : []),\n    ...presets.map((p) => ({ key: `p${p}`, label: `${pct(p)}%`, hint: p === 1 ? `${mod}0` : undefined, value: p, selected: pct(p) === pct(value) })),\n  ];\n\n  const onOpenChange = (next: boolean) => {\n    if (next) {\n      setText(String(pct(value)));\n      setError(false);\n      setActive(-1);\n    }\n    setOpen(next);\n  };\n\n  const choose = (o: Option) => {\n    set(o.value, \"preset\");\n    setOpen(false);\n  };\n\n  const commitText = () => {\n    const n = parseFloat(text.replace(/[%\\s,]/g, \"\"));\n    if (!Number.isFinite(n)) {\n      setError(true);\n      inputRef.current?.select();\n      return;\n    }\n    // Out-of-range numbers clamp rather than fail: 900 means \"as far as it goes\".\n    set(n / 100, \"input\");\n    setOpen(false);\n  };\n\n  const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      setVia(\"keyboard\");\n      const n = options.length;\n      // -1 is the input itself; the list wraps through it.\n      setActive((i) => (e.key === \"ArrowDown\" ? (i + 1 >= n ? -1 : i + 1) : i - 1 < -1 ? n - 1 : i - 1));\n    } else if (e.key === \"Enter\") {\n      e.preventDefault();\n      if (active >= 0) choose(options[active]);\n      else commitText();\n    }\n  };\n\n  const h = size === \"sm\" ? \"h-6\" : \"h-7\";\n  const iconBtn = cn(\n    \"relative grid shrink-0 place-items-center rounded-md text-fg-2\",\n    size === \"sm\" ? \"size-6\" : \"size-7\",\n    \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n    \"transition-[background-color,color,scale,opacity] duration-150 ease-out active:scale-[0.92] active:duration-75\",\n    \"hover:bg-hover hover:text-fg\",\n    \"aria-disabled:pointer-events-none aria-disabled:opacity-40\",\n    // Invisible 44px target on touch screens.\n    \"[@media(pointer:coarse)]:before:absolute [@media(pointer:coarse)]:before:-inset-2\",\n  );\n  const tipPopup =\n    \"flex items-center gap-2 rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] text-fg shadow-pop origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out-expo data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0 data-[ending-style]:scale-[0.97] data-[ending-style]:opacity-0 data-[ending-style]:duration-100 data-[instant]:transition-none\";\n\n  const button = (dir: 1 | -1) => {\n    const enabled = dir > 0 ? canIn : canOut;\n    const label = dir > 0 ? \"Zoom in\" : \"Zoom out\";\n    return (\n      <Tooltip.Root>\n        <Tooltip.Trigger\n          type=\"button\"\n          aria-label={label}\n          aria-disabled={!enabled || undefined}\n          className={iconBtn}\n          {...pressHandlers(dir, enabled)}\n        >\n          {dir > 0 ? <Plus size={size === \"sm\" ? 14 : 16} /> : <Minus size={size === \"sm\" ? 14 : 16} />}\n        </Tooltip.Trigger>\n        <Tooltip.Portal>\n          <Tooltip.Positioner side={side} sideOffset={8} className=\"z-(--z-tooltip)\">\n            <Tooltip.Popup className={tipPopup}>\n              {label}\n              <kbd className=\"font-mono text-[11px] text-fg-3\">{dir > 0 ? `${mod}+` : `${mod}−`}</kbd>\n            </Tooltip.Popup>\n          </Tooltip.Positioner>\n        </Tooltip.Portal>\n      </Tooltip.Root>\n    );\n  };\n\n  return (\n    <Tooltip.Provider delay={500}>\n      <div\n        role=\"group\"\n        aria-label=\"Zoom\"\n        data-size={size}\n        data-disabled={disabled || undefined}\n        className={cn(\n          \"inline-flex items-center gap-px rounded-lg border border-line-2 bg-raised p-0.5 shadow-[var(--shadow)]\",\n          disabled && \"opacity-50\",\n          className,\n        )}\n        {...rest}\n      >\n        {button(-1)}\n\n        <Popover.Root open={open} onOpenChange={onOpenChange}>\n          <Popover.Trigger\n            disabled={disabled}\n            aria-label={`Zoom level ${pct(value)}%`}\n            className={cn(\n              \"group/level flex items-center justify-center gap-0.5 rounded-md pl-1.5 pr-1 text-[12.5px] font-medium text-fg\",\n              h,\n              size === \"sm\" ? \"w-[58px]\" : \"w-[64px]\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n              \"transition-[background-color,scale] duration-150 ease-out hover:bg-hover active:scale-[0.97] active:duration-75 data-[popup-open]:bg-hover\",\n            )}\n          >\n            <NumberFlow\n              value={pct(value)}\n              suffix=\"%\"\n              animated={!streaming}\n              transformTiming={ROLL}\n              spinTiming={ROLL}\n              className=\"tabular\"\n            />\n            <ChevronDown\n              size={12}\n              className=\"shrink-0 text-fg-3 transition-transform duration-150 ease-out group-data-[popup-open]/level:rotate-180 motion-reduce:transition-none\"\n            />\n          </Popover.Trigger>\n          <Popover.Portal>\n            <Popover.Positioner side={side} sideOffset={6} align=\"center\" className=\"z-(--z-popover)\">\n              <Popover.Popup\n                initialFocus={inputRef}\n                className={cn(\n                  \"w-[212px] rounded-xl border border-line-2 bg-raised p-1 shadow-pop outline-none\",\n                  \"origin-[var(--transform-origin)] transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n                  \"data-[starting-style]:scale-[0.96] data-[starting-style]:opacity-0\",\n                  \"data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[ending-style]:duration-100\",\n                  \"motion-reduce:data-[starting-style]:scale-100 motion-reduce:data-[ending-style]:scale-100\",\n                )}\n              >\n                <div\n                  className={cn(\n                    \"flex h-8 items-center rounded-lg border bg-frame pl-2.5 pr-2 transition-[border-color,box-shadow] duration-150\",\n                    \"focus-within:ring-2 focus-within:ring-fg/10\",\n                    error ? \"border-danger focus-within:ring-danger/20\" : \"border-line-2 focus-within:border-fg-4\",\n                  )}\n                >\n                  <input\n                    ref={inputRef}\n                    role=\"combobox\"\n                    aria-label=\"Zoom percentage\"\n                    aria-expanded\n                    aria-controls={listId}\n                    aria-activedescendant={active >= 0 ? `${uid}-${options[active].key}` : undefined}\n                    aria-invalid={error || undefined}\n                    aria-describedby={error ? errorId : undefined}\n                    inputMode=\"decimal\"\n                    enterKeyHint=\"done\"\n                    autoComplete=\"off\"\n                    spellCheck={false}\n                    value={text}\n                    onFocus={(e) => e.currentTarget.select()}\n                    onChange={(e) => {\n                      setText(e.target.value);\n                      setError(false);\n                      setActive(-1);\n                    }}\n                    onKeyDown={onInputKey}\n                    className=\"tabular min-w-0 flex-1 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n                  />\n                  <span aria-hidden className=\"text-[13px] text-fg-3\">\n                    %\n                  </span>\n                </div>\n                {error && (\n                  <p id={errorId} role=\"alert\" className=\"px-1.5 pt-1.5 text-[11.5px] leading-[1.4] text-danger\">\n                    Enter a number, like 150\n                  </p>\n                )}\n\n                <LayoutGroup id={uid}>\n                  <div\n                    id={listId}\n                    role=\"listbox\"\n                    aria-label=\"Zoom presets\"\n                    onPointerLeave={() => setActive(-1)}\n                    className=\"mt-1 flex flex-col\"\n                  >\n                    {options.map((o, i) => (\n                      <div key={o.key} className=\"contents\">\n                        {(o.key === \"fit\" || (o.key === `p${presets[0]}` && fitValue === undefined)) && (\n                          <div role=\"separator\" className=\"mx-1.5 my-1 h-px bg-line\" />\n                        )}\n                        <div\n                          id={`${uid}-${o.key}`}\n                          role=\"option\"\n                          aria-selected={!!o.selected}\n                          onPointerMove={() => {\n                            if (active !== i) {\n                              setVia(\"pointer\");\n                              setActive(i);\n                            }\n                          }}\n                          onPointerDown={(e) => e.preventDefault()}\n                          onClick={() => choose(o)}\n                          className=\"relative flex h-8 cursor-default select-none items-center gap-2 rounded-md px-2 text-[13px] text-fg\"\n                        >\n                          {active === i && (\n                            // One highlight glides between rows; arrow keys move it without easing.\n                            <motion.span\n                              layoutId=\"zoom-highlight\"\n                              aria-hidden\n                              className=\"absolute inset-0 rounded-md bg-hover\"\n                              transition={via === \"keyboard\" || reduce ? { duration: 0 } : spring.follow}\n                            />\n                          )}\n                          <span className=\"relative grid size-4 place-items-center text-fg\">\n                            {o.selected && <Check size={14} />}\n                          </span>\n                          <span className=\"relative flex-1\">{o.label}</span>\n                          {o.hint && <kbd className=\"relative font-mono text-[11px] text-fg-3\">{o.hint}</kbd>}\n                        </div>\n                      </div>\n                    ))}\n                  </div>\n                </LayoutGroup>\n              </Popover.Popup>\n            </Popover.Positioner>\n          </Popover.Portal>\n        </Popover.Root>\n\n        {button(1)}\n      </div>\n    </Tooltip.Provider>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/zoom-control"
}
