{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "resizable-box",
  "type": "registry:ui",
  "title": "Resizable Box",
  "description": "Eight handles, a live size readout, Shift for ratio, Alt from center.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/resizable-box.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useEffectEvent, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type BoxRect = { x: number; y: number; width: number; height: number };\nexport type ResizeHandle = \"n\" | \"s\" | \"e\" | \"w\" | \"ne\" | \"nw\" | \"se\" | \"sw\";\n\nexport type ResizableBoxProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"> & {\n  children?: React.ReactNode;\n  /** Position inside the parent and size, in px. */\n  value?: BoxRect;\n  defaultValue?: BoxRect;\n  /** Called at most once a frame while resizing, and once more when it settles. */\n  onValueChange?: (rect: BoxRect) => void;\n  /** Called once when a resize ends, with the final rect. */\n  onResizeEnd?: (rect: BoxRect) => void;\n  minWidth?: number;\n  minHeight?: number;\n  maxWidth?: number;\n  maxHeight?: number;\n  /** Keep the box inside its offset parent. */\n  bounded?: boolean;\n  /** Always keep the proportions, not only while Shift is held. */\n  lockAspect?: boolean;\n  handles?: ResizeHandle[];\n  /** Show the width × height readout while resizing. */\n  readout?: boolean;\n  disabled?: boolean;\n};\n\nconst ALL: ResizeHandle[] = [\"nw\", \"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\"];\nconst cursors: Record<ResizeHandle, string> = { n: \"ns-resize\", s: \"ns-resize\", e: \"ew-resize\", w: \"ew-resize\", ne: \"nesw-resize\", sw: \"nesw-resize\", nw: \"nwse-resize\", se: \"nwse-resize\" };\n/** Past a limit the edge gives a little, and never more than `dim` px. */\nconst rubber = (over: number, dim = 14) => (over * dim * 0.55) / (dim + over * 0.55);\n\nexport function ResizableBox({\n  children,\n  value,\n  defaultValue = { x: 0, y: 0, width: 240, height: 160 },\n  onValueChange,\n  onResizeEnd,\n  minWidth = 48,\n  minHeight = 48,\n  maxWidth = Infinity,\n  maxHeight = Infinity,\n  bounded = true,\n  lockAspect = false,\n  handles = ALL,\n  readout = true,\n  disabled = false,\n  className,\n  style,\n  onKeyDown,\n  \"aria-label\": ariaLabel = \"Resizable box\",\n  ...rest\n}: ResizableBoxProps) {\n  const reduce = useReducedMotion();\n  const start = value ?? defaultValue;\n  const x = useMotionValue(start.x);\n  const y = useMotionValue(start.y);\n  const w = useMotionValue(start.width);\n  const h = useMotionValue(start.height);\n  const initial = useRef(start);\n\n  const [active, setActive] = useState<ResizeHandle | null>(null);\n  const [locked, setLocked] = useState(false);\n  const [limit, setLimit] = useState<\"min\" | \"max\" | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const hintId = useId();\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const anims = useRef<AnimationPlaybackControls[]>([]);\n  const d = useRef({ id: -1, handle: \"se\" as ResizeHandle, x0: 0, y0: 0, px: 0, py: 0, r0: start, bounds: { w: Infinity, h: Infinity }, shift: false, alt: false });\n  // The parent's height, for deciding where the readout fits. Kept as a motion value so render never reads a ref.\n  const room = useMotionValue(Infinity);\n  const announceTimer = useRef(0);\n  const emit = useRef(0);\n\n  const rect = (): BoxRect => ({ x: x.get(), y: y.get(), width: w.get(), height: h.get() });\n  const stop = () => {\n    anims.current.forEach((a) => a.stop());\n    anims.current = [];\n  };\n\n  useEffect(\n    () => () => {\n      anims.current.forEach((a) => a.stop());\n      window.clearTimeout(announceTimer.current);\n      cancelAnimationFrame(emit.current);\n    },\n    [],\n  );\n\n  // A controlled value that changes from outside moves the box there, unless a hand is on it.\n  useEffect(() => {\n    if (!value || d.current.id !== -1) return;\n    x.set(value.x);\n    y.set(value.y);\n    w.set(value.width);\n    h.set(value.height);\n  }, [value, x, y, w, h]);\n\n  const report = () => {\n    if (emit.current) return;\n    emit.current = requestAnimationFrame(() => {\n      emit.current = 0;\n      onValueChange?.(rect());\n    });\n  };\n\n  const parentSize = () => {\n    const p = rootRef.current?.offsetParent as HTMLElement | null;\n    return bounded && p ? { w: p.clientWidth, h: p.clientHeight } : { w: Infinity, h: Infinity };\n  };\n\n  /**\n   * The geometry. Moves the dragged edges by (dx, dy), keeps the opposite edges fixed (or the\n   * center, with Alt), holds the ratio when asked, then clamps: hard at the parent, soft at min/max.\n   */\n  function solve(handle: ResizeHandle, dx: number, dy: number, r0: BoxRect, bounds: { w: number; h: number }, keepRatio: boolean, fromCenter: boolean, soft: boolean) {\n    const hasE = handle.includes(\"e\");\n    const hasW = handle.includes(\"w\");\n    const hasN = handle.includes(\"n\");\n    const hasS = handle.includes(\"s\");\n    const k = fromCenter ? 2 : 1;\n    let width = r0.width + (hasE ? dx * k : hasW ? -dx * k : 0);\n    let height = r0.height + (hasS ? dy * k : hasN ? -dy * k : 0);\n    const ratio = r0.width / r0.height;\n\n    if (keepRatio) {\n      const horizontal = hasE || hasW;\n      const vertical = hasN || hasS;\n      // On a corner the axis that moved further (relative to size) leads; on an edge, that edge leads.\n      if (horizontal && (!vertical || Math.abs(width / r0.width - 1) >= Math.abs(height / r0.height - 1))) height = width / ratio;\n      else width = height * ratio;\n    }\n\n    // How much room each side has before it meets the parent.\n    const cx = r0.x + r0.width / 2;\n    const cy = r0.y + r0.height / 2;\n    const roomW = fromCenter ? 2 * Math.min(cx, bounds.w - cx) : hasW ? r0.x + r0.width : hasE ? bounds.w - r0.x : keepRatio ? 2 * Math.min(cx, bounds.w - cx) : Infinity;\n    const roomH = fromCenter ? 2 * Math.min(cy, bounds.h - cy) : hasN ? r0.y + r0.height : hasS ? bounds.h - r0.y : keepRatio ? 2 * Math.min(cy, bounds.h - cy) : Infinity;\n    let loW = minWidth;\n    let loH = minHeight;\n    let hiW = Math.min(maxWidth, roomW);\n    let hiH = Math.min(maxHeight, roomH);\n    if (keepRatio) {\n      loW = Math.max(loW, loH * ratio);\n      hiW = Math.min(hiW, hiH * ratio);\n      loH = loW / ratio;\n      hiH = hiW / ratio;\n    }\n\n    let hit: \"min\" | \"max\" | null = null;\n    const fit = (v: number, lo: number, hi: number, wall: number) => {\n      if (v < lo) {\n        hit = \"min\";\n        return soft ? lo - rubber(lo - v) : lo;\n      }\n      if (v > hi) {\n        hit = \"max\";\n        // The parent is a wall; a max size is a limit you can lean on.\n        return soft && hi < wall ? hi + rubber(v - hi) : hi;\n      }\n      return v;\n    };\n    width = fit(width, loW, hiW, roomW);\n    height = keepRatio ? width / ratio : fit(height, loH, hiH, roomH);\n\n    let nx = r0.x;\n    let ny = r0.y;\n    if (fromCenter) {\n      nx = cx - width / 2;\n      ny = cy - height / 2;\n    } else {\n      if (hasW) nx = r0.x + r0.width - width;\n      else if (!hasE && keepRatio) nx = cx - width / 2;\n      if (hasN) ny = r0.y + r0.height - height;\n      else if (!hasS && keepRatio) ny = cy - height / 2;\n    }\n    return { rect: { x: nx, y: ny, width, height }, hit: hit as \"min\" | \"max\" | null };\n  }\n\n  const apply = (r: BoxRect) => {\n    x.set(r.x);\n    y.set(r.y);\n    w.set(r.width);\n    h.set(r.height);\n    report();\n  };\n\n  const settle = (target: BoxRect) => {\n    stop();\n    if (reduce) apply(target);\n    else anims.current = [animate(x, target.x, spring.snappy), animate(y, target.y, spring.snappy), animate(w, target.width, spring.snappy), animate(h, target.height, spring.snappy)];\n    const unsub = w.on(\"change\", report);\n    const done = () => {\n      unsub();\n      onValueChange?.(target);\n      onResizeEnd?.(target);\n    };\n    if (reduce || !anims.current.length) done();\n    else Promise.all(anims.current.map((a) => a.finished)).then(done, done);\n  };\n\n  const recompute = () => {\n    const s = d.current;\n    if (s.id === -1) return;\n    const { rect: r, hit } = solve(s.handle, s.px, s.py, s.r0, s.bounds, lockAspect || s.shift, s.alt, true);\n    apply(r);\n    setLimit(hit);\n    setLocked(lockAspect || s.shift);\n  };\n\n  // ---- pointer on a handle -------------------------------------------------------------------\n\n  function onHandleDown(handle: ResizeHandle, e: React.PointerEvent<HTMLSpanElement>) {\n    if (disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    e.preventDefault();\n    e.stopPropagation();\n    stop();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    rootRef.current?.focus({ preventScroll: true });\n    const bounds = parentSize();\n    d.current = { id: e.pointerId, handle, x0: e.clientX, y0: e.clientY, px: 0, py: 0, r0: rect(), bounds, shift: e.shiftKey, alt: e.altKey };\n    room.set(bounds.h);\n    setActive(handle);\n    setLocked(lockAspect || e.shiftKey);\n    setLimit(null);\n  }\n\n  function onHandleMove(e: React.PointerEvent<HTMLSpanElement>) {\n    const s = d.current;\n    if (s.id !== e.pointerId) return;\n    s.px = e.clientX - s.x0;\n    s.py = e.clientY - s.y0;\n    s.shift = e.shiftKey;\n    s.alt = e.altKey;\n    recompute();\n  }\n\n  function onHandleUp(e: React.PointerEvent<HTMLSpanElement>) {\n    const s = d.current;\n    if (s.id !== e.pointerId) return;\n    s.id = -1;\n    setActive(null);\n    setLimit(null);\n    setLocked(false);\n    // Anything leaning past a limit springs back to it.\n    const { rect: r } = solve(s.handle, s.px, s.py, s.r0, s.bounds, lockAspect || s.shift, s.alt, false);\n    settle(r);\n    say(r);\n  }\n\n  // Shift and Alt can change mid-drag without the pointer moving.\n  const onModifier = useEffectEvent((e: KeyboardEvent) => {\n    const s = d.current;\n    if (s.id === -1) return;\n    if (e.key === \"Escape\") {\n      // Escape puts it back exactly where the drag began.\n      s.id = -1;\n      setActive(null);\n      setLimit(null);\n      setLocked(false);\n      settle(s.r0);\n      return;\n    }\n    if (e.key !== \"Shift\" && e.key !== \"Alt\") return;\n    s.shift = e.shiftKey;\n    s.alt = e.altKey;\n    recompute();\n  });\n  useEffect(() => {\n    const h = (e: KeyboardEvent) => onModifier(e);\n    window.addEventListener(\"keydown\", h);\n    window.addEventListener(\"keyup\", h);\n    return () => {\n      window.removeEventListener(\"keydown\", h);\n      window.removeEventListener(\"keyup\", h);\n    };\n  }, []);\n\n  function onHandleDoubleClick(e: React.MouseEvent) {\n    e.stopPropagation();\n    // Back to the size it started at, keeping the top left corner where it is.\n    const r0 = initial.current;\n    const { rect: r } = solve(\"se\", r0.width - w.get(), r0.height - h.get(), rect(), parentSize(), false, false, false);\n    settle(r);\n    say(r);\n  }\n\n  // ---- keyboard -----------------------------------------------------------------------------\n\n  const say = (r: BoxRect) => {\n    window.clearTimeout(announceTimer.current);\n    announceTimer.current = window.setTimeout(() => setAnnounce(`${Math.round(r.width)} by ${Math.round(r.height)} pixels`), 350);\n  };\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {\n    onKeyDown?.(e);\n    if (e.defaultPrevented || disabled || e.target !== e.currentTarget || !e.key.startsWith(\"Arrow\")) return;\n    e.preventDefault();\n    e.stopPropagation();\n    stop();\n    const step = e.shiftKey ? 10 : 1;\n    const horizontal = e.key === \"ArrowLeft\" || e.key === \"ArrowRight\";\n    const delta = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? step : -step;\n    // An arrow moves an edge in its direction: the right and bottom edges, or with Alt the left and top.\n    const handle: ResizeHandle = horizontal ? (e.altKey ? \"w\" : \"e\") : e.altKey ? \"n\" : \"s\";\n    const { rect: r, hit } = solve(handle, horizontal ? delta : 0, horizontal ? 0 : delta, rect(), parentSize(), lockAspect, false, false);\n    apply(r);\n    onValueChange?.(r);\n    onResizeEnd?.(r);\n    say(r);\n    if (hit) {\n      setLimit(hit);\n      window.setTimeout(() => setLimit(null), 600);\n    }\n  }\n\n  // ---- render -------------------------------------------------------------------------------\n\n  const label = useTransform(() => `${Math.round(w.get())} × ${Math.round(h.get())}`);\n  // The readout sits under the box, or inside its bottom edge when the parent has no room below.\n  const below = useTransform(() => y.get() + h.get() + 40 < room.get());\n  const readoutTop = useTransform(() => (below.get() ? h.get() + 10 : h.get() - 36));\n  const dragging = active !== null;\n\n  return (\n    <motion.div\n      ref={rootRef}\n      tabIndex={disabled ? -1 : 0}\n      role=\"group\"\n      aria-roledescription=\"resizable box\"\n      aria-label={ariaLabel}\n      aria-describedby={hintId}\n      data-state={dragging ? \"resizing\" : \"idle\"}\n      data-disabled={disabled || undefined}\n      onKeyDown={handleKeyDown}\n      style={{ ...style, left: x, top: y, width: w, height: h }}\n      className={cn(\n        \"group/box absolute touch-none outline-none\",\n        // The frame: a hairline at rest, stronger when hovered, focused or held.\n        \"after:pointer-events-none after:absolute after:-inset-px after:rounded-[inherit] after:border after:border-transparent after:transition-[border-color] after:duration-150 after:content-['']\",\n        \"hover:after:border-fg/40 focus-visible:after:border-fg/70 data-[state=resizing]:after:border-fg/70\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n\n      {/* Shift's ratio lock draws the diagonal it's holding. */}\n      <AnimatePresence>\n        {dragging && locked && (\n          <motion.svg\n            key=\"diagonal\"\n            aria-hidden\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            className=\"pointer-events-none absolute inset-0 h-full w-full overflow-visible text-fg/50\"\n          >\n            <line\n              x1={active === \"ne\" || active === \"sw\" ? \"100%\" : \"0\"}\n              y1=\"0\"\n              x2={active === \"ne\" || active === \"sw\" ? \"0\" : \"100%\"}\n              y2=\"100%\"\n              stroke=\"currentColor\"\n              strokeWidth={1}\n              strokeDasharray=\"3 3\"\n              vectorEffect=\"non-scaling-stroke\"\n            />\n          </motion.svg>\n        )}\n      </AnimatePresence>\n\n      {!disabled &&\n        handles.map((hd) => (\n          <Handle\n            key={hd}\n            handle={hd}\n            active={active === hd}\n            dimmed={dragging && active !== hd}\n            onPointerDown={(e) => onHandleDown(hd, e)}\n            onPointerMove={onHandleMove}\n            onPointerUp={onHandleUp}\n            onPointerCancel={onHandleUp}\n            onDoubleClick={onHandleDoubleClick}\n          />\n        ))}\n\n      <AnimatePresence>\n        {readout && dragging && (\n          <motion.div\n            key=\"readout\"\n            aria-hidden\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={{ duration: 0.16, ease: ease.out }}\n            style={{ top: readoutTop }}\n            className=\"pointer-events-none absolute left-1/2 z-20 -ml-[60px] flex w-[120px] justify-center\"\n          >\n            <span\n              className={cn(\n                \"flex h-6 items-center gap-1.5 whitespace-nowrap rounded-md border border-line-2 bg-raised px-2 font-mono text-[11px] text-fg shadow-pop transition-colors duration-150\",\n                limit && \"text-fg-2\",\n              )}\n            >\n              <motion.span className=\"tabular\">{label}</motion.span>\n              {locked && <LockGlyph />}\n              {limit && <span className=\"text-2xs uppercase tracking-[0.08em] text-fg-3\">{limit}</span>}\n            </span>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <span id={hintId} className=\"sr-only\">\n        Drag a handle to resize. With the box focused, arrow keys resize from the right and bottom edges, Alt and arrows from the left and top, and Shift moves 10 pixels at a time.\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n    </motion.div>\n  );\n}\n\nfunction Handle({\n  handle,\n  active,\n  dimmed,\n  ...events\n}: {\n  handle: ResizeHandle;\n  active: boolean;\n  dimmed: boolean;\n  onPointerDown: (e: React.PointerEvent<HTMLSpanElement>) => void;\n  onPointerMove: (e: React.PointerEvent<HTMLSpanElement>) => void;\n  onPointerUp: (e: React.PointerEvent<HTMLSpanElement>) => void;\n  onPointerCancel: (e: React.PointerEvent<HTMLSpanElement>) => void;\n  onDoubleClick: (e: React.MouseEvent) => void;\n}) {\n  const corner = handle.length === 2;\n  const pos: Record<ResizeHandle, string> = {\n    nw: \"left-0 top-0\",\n    n: \"left-1/2 top-0\",\n    ne: \"left-full top-0\",\n    e: \"left-full top-1/2\",\n    se: \"left-full top-full\",\n    s: \"left-1/2 top-full\",\n    sw: \"left-0 top-full\",\n    w: \"left-0 top-1/2\",\n  };\n  return (\n    <span\n      aria-hidden\n      data-handle={handle}\n      data-active={active || undefined}\n      style={{ cursor: cursors[handle] }}\n      {...events}\n      className={cn(\n        // The hit area is 20px on a mouse and 44px under a finger, centered on the edge or corner.\n        \"group/handle absolute z-10 grid size-5 -translate-x-1/2 -translate-y-1/2 place-items-center pointer-coarse:size-11\",\n        pos[handle],\n        // Edge handles only take the middle of their edge; corners win where they meet.\n        !corner && (handle === \"n\" || handle === \"s\" ? \"w-[max(20px,calc(100%-40px))] pointer-coarse:w-[max(44px,calc(100%-88px))]\" : \"h-[max(20px,calc(100%-40px))] pointer-coarse:h-[max(44px,calc(100%-88px))]\"),\n        \"transition-opacity duration-150\",\n        // Hidden until the box is hovered, focused or held, except on touch where there is no hover.\n        \"opacity-0 group-hover/box:opacity-100 group-focus-visible/box:opacity-100 group-data-[state=resizing]/box:opacity-100 pointer-coarse:opacity-100\",\n        dimmed && \"opacity-40! group-hover/box:opacity-40!\",\n      )}\n    >\n      <span\n        className={cn(\n          \"block border border-fg/70 bg-raised shadow-[var(--shadow)] transition-[scale,background-color] duration-150 ease-out-expo\",\n          corner ? \"size-[9px] rounded-[3px]\" : handle === \"n\" || handle === \"s\" ? \"h-[5px] w-4 rounded-full\" : \"h-4 w-[5px] rounded-full\",\n          \"group-hover/handle:scale-125 group-data-[active]/handle:scale-125 group-data-[active]/handle:bg-fg\",\n          \"motion-reduce:group-hover/handle:scale-100 motion-reduce:group-data-[active]/handle:scale-100\",\n        )}\n      />\n    </span>\n  );\n}\n\nfunction LockGlyph() {\n  return (\n    <svg width=\"11\" height=\"11\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"text-fg-3\">\n      <rect x=\"3.5\" y=\"7\" width=\"9\" height=\"6.5\" rx=\"1.5\" />\n      <path d=\"M5.5 7V5.25a2.5 2.5 0 0 1 5 0V7\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/resizable-box"
}
