{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"tape-measure","type":"registry:component","title":"Tape measure","description":"Pull the blade out to measure; release the lock and it reels itself back in.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/tape-measure.tsx","type":"registry:component","content":"\"use client\";\n\nimport { memo, useEffect, useId, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useTransform,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// Pixels per centimetre. Wide enough that a 1mm step is still a whole pixel\n// under the finger, so fine adjustments are actually reachable.\nconst SCALE = 10;\nconst CASE_W = 132;\nconst BLADE_H = 26;\n// The tab is only grabbable near its own edge; this is the reach past it.\nconst TAB_REACH = 28;\n// Within this of a whole centimetre the blade drops into the detent; between\n// detents it moves in 1mm steps.\nconst DETENT = 0.15;\n// Keyboard moves and external changes slide the blade with no overshoot,\n// so the reading never passes the value it is heading for.\nconst SLIDE = { type: \"spring\", stiffness: 500, damping: 45 } as const;\n// The retract motor: fast, and just underdamped enough that a long blade\n// slams the tab into the mouth. That overshoot becomes the case's jolt.\nconst RETRACT = { type: \"spring\", stiffness: 380, damping: 28 } as const;\n// The largest jolt the case takes from the tab hitting it, in px.\nconst MAX_JOLT = 8;\n\n// A tape measure is a physical object: it keeps its own materials in both\n// themes. Safety-yellow housing and blade, black rubber, plated steel, and\n// a red lock so you can see at a glance whether it is set.\nconst HOUSING = \"linear-gradient(170deg, oklch(0.87 0.16 88), oklch(0.78 0.16 78))\";\nconst RUBBER = \"oklch(0.22 0.005 60)\";\nconst STEEL = \"linear-gradient(135deg, oklch(0.93 0 0), oklch(0.66 0 0) 55%, oklch(0.82 0 0))\";\nconst RIVET = \"oklch(0.5 0 0)\";\nconst LOCK = \"oklch(0.58 0.2 28)\";\nconst BLADE = \"oklch(0.9 0.15 95)\";\nconst BLADE_INK = \"oklch(0.2 0.01 60)\";\nconst BLADE_RED = \"oklch(0.52 0.2 28)\";\n\nfunction detent(raw: number) {\n  const whole = Math.round(raw);\n  if (Math.abs(raw - whole) < DETENT) return whole;\n  return Math.round(raw * 10) / 10;\n}\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(Math.max(v, lo), hi);\n}\n\nconst format = (cm: number) => Math.max(0, cm).toFixed(1);\n\nexport function TapeMeasure({\n  value,\n  onChange,\n  label,\n  max = 300,\n  className,\n}: {\n  /** Length in centimetres, to one decimal. */\n  value: number;\n  onChange: (value: number) => void;\n  label: string;\n  max?: number;\n  className?: string;\n}) {\n  const id = useId();\n  const reduceMotion = useReducedMotion();\n  const length = useMotionValue(value);\n  const [locked, setLocked] = useState(true);\n  // The readout and ARIA value are written straight from the motion value,\n  // so React only ever renders the first one.\n  const [initial] = useState(() => format(value));\n\n  const sliderRef = useRef<HTMLDivElement>(null);\n  const readoutRef = useRef<HTMLSpanElement>(null);\n  const anim = useRef<ReturnType<typeof animate> | null>(null);\n  const drag = useRef<{ id: number; startX: number; start: number } | null>(null);\n  // The last value handed to onChange, so a prop change can be told apart\n  // from our own echo.\n  const committed = useRef(value);\n\n  const shown = useTransform(length, (l) => Math.max(l, 0));\n  const stripX = useTransform(shown, (l) => (l - max) * SCALE);\n  const tabX = useTransform(shown, (l) => l * SCALE);\n  // Past zero, the tab is inside the mouth: the whole tape shoves left.\n  const jolt = useTransform(length, (l) =>\n    l < 0 ? -MAX_JOLT * Math.tanh((-l * SCALE) / 40) : 0,\n  );\n\n  // The reading ticks along with the blade without re-rendering per frame.\n  useMotionValueEvent(length, \"change\", (l) => {\n    const text = format(l);\n    if (readoutRef.current) readoutRef.current.textContent = text;\n    sliderRef.current?.setAttribute(\"aria-valuenow\", text);\n    sliderRef.current?.setAttribute(\"aria-valuetext\", `${text} centimetres`);\n  });\n\n  const halt = () => {\n    anim.current?.stop();\n    anim.current = null;\n  };\n\n  const commit = (v: number) => {\n    const next = Math.round(clamp(v, 0, max) * 10) / 10;\n    committed.current = next;\n    onChange(next);\n  };\n\n  const slideTo = (v: number) => {\n    halt();\n    if (reduceMotion) length.jump(v);\n    else anim.current = animate(length, v, SLIDE);\n  };\n\n  useEffect(() => {\n    if (Math.abs(value - committed.current) < 1e-6) return;\n    committed.current = value;\n    anim.current?.stop();\n    if (reduceMotion) length.jump(value);\n    else anim.current = animate(length, value, SLIDE);\n  }, [value, reduceMotion, length]);\n\n  useEffect(() => () => anim.current?.stop(), []);\n\n  const retract = () => {\n    halt();\n    commit(0);\n    if (reduceMotion) {\n      length.jump(0);\n      return;\n    }\n    anim.current = animate(length, 0, { ...RETRACT, velocity: length.getVelocity() });\n  };\n\n  const toggleLock = () => {\n    if (locked && length.get() > 0) {\n      setLocked(false);\n      retract();\n    } else {\n      setLocked(!locked);\n    }\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 || drag.current) return;\n    const box = e.currentTarget.getBoundingClientRect();\n    if (e.clientX - box.left > shown.get() * SCALE + TAB_REACH) return;\n    halt();\n    e.currentTarget.setPointerCapture(e.pointerId);\n    // A thumb on the lock is what keeps a real tape out; grabbing sets it.\n    setLocked(true);\n    drag.current = { id: e.pointerId, startX: e.clientX, start: shown.get() };\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || e.pointerId !== d.id) return;\n    const raw = d.start + (e.clientX - d.startX) / SCALE;\n    length.set(detent(clamp(raw, 0, max)));\n  };\n\n  const onPointerEnd = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || e.pointerId !== d.id) return;\n    drag.current = null;\n    const velocity = length.getVelocity();\n    // A flick keeps sliding on its own speed, then settles into a detent.\n    if (!reduceMotion && Math.abs(velocity) > 15) {\n      anim.current = animate(length, length.get(), {\n        type: \"inertia\",\n        velocity,\n        power: 0.5,\n        timeConstant: 220,\n        min: 0,\n        max,\n        bounceStiffness: 600,\n        bounceDamping: 40,\n        modifyTarget: Math.round,\n        onComplete: () => commit(length.get()),\n      });\n      return;\n    }\n    commit(length.get());\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const step = e.shiftKey ? 10 : 1;\n    const base = committed.current;\n    let next: number;\n    // Keys land on whole centimetres, the way a detent would catch them.\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") next = Math.floor(base + 1e-6) + step;\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") next = Math.ceil(base - 1e-6) - step;\n    else if (e.key === \"PageUp\") next = Math.floor(base + 1e-6) + 10;\n    else if (e.key === \"PageDown\") next = Math.ceil(base - 1e-6) - 10;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = max;\n    else return;\n    e.preventDefault();\n    next = clamp(next, 0, max);\n    setLocked(true);\n    commit(next);\n    slideTo(next);\n  };\n\n  return (\n    <div className={cn(\"flex w-[480px] max-w-full flex-col gap-5\", className)}>\n      <div className=\"flex items-end justify-between gap-4\">\n        <div className=\"flex flex-col gap-1\">\n          <span id={`${id}-label`} className=\"text-sm text-muted-foreground\">\n            {label}\n          </span>\n          <span className=\"flex items-baseline gap-1.5\">\n            <span ref={readoutRef} className=\"text-4xl font-medium tracking-tight tabular-nums\">\n              {initial}\n            </span>\n            <span className=\"text-lg text-muted-foreground\">cm</span>\n          </span>\n        </div>\n        <span className=\"mb-1 flex h-7 items-center gap-1.5 rounded-full bg-muted px-2.5 text-[13px] text-muted-foreground\">\n          <span\n            aria-hidden\n            className=\"size-1.5 rounded-full transition-[background-color] duration-150 ease-out\"\n            style={{ background: locked ? LOCK : \"var(--muted-foreground)\" }}\n          />\n          {locked ? \"Locked\" : \"Free\"}\n        </span>\n      </div>\n\n      <div className=\"relative h-[124px] w-full\">\n        <motion.div className=\"absolute inset-0\" style={{ x: jolt }}>\n          <div\n            ref={sliderRef}\n            role=\"slider\"\n            tabIndex={0}\n            aria-labelledby={`${id}-label`}\n            aria-valuemin={0}\n            aria-valuemax={max}\n            aria-valuenow={Number(initial)}\n            aria-valuetext={`${initial} centimetres`}\n            aria-describedby={`${id}-hint`}\n            className=\"absolute top-[72px] right-0 h-12 cursor-grab touch-none overflow-hidden outline-hidden select-none [mask-image:linear-gradient(to_right,black_calc(100%_-_48px),transparent)] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:cursor-grabbing\"\n            style={{ left: CASE_W }}\n            onPointerDown={onPointerDown}\n            onPointerMove={onPointerMove}\n            onPointerUp={onPointerEnd}\n            onPointerCancel={onPointerEnd}\n            onKeyDown={onKeyDown}\n          >\n            <motion.div className=\"absolute top-2 left-0\" style={{ x: stripX }}>\n              <Blade max={max} />\n            </motion.div>\n            {/* The hook: a riveted plate on the blade's end, bent up into\n                a tab that stands proud of the blade on both sides. */}\n            <motion.div className=\"absolute top-0 left-0\" style={{ x: tabX }}>\n              <span\n                className=\"absolute top-[10px] -left-[13px] flex h-[24px] w-[14px] flex-col items-center justify-center gap-[7px] rounded-l-[2px]\"\n                style={{ background: STEEL }}\n              >\n                <span className=\"size-[4px] rounded-full\" style={{ background: RIVET }} />\n                <span className=\"size-[4px] rounded-full\" style={{ background: RIVET }} />\n              </span>\n              <span\n                className=\"absolute top-[2px] left-0 h-[42px] w-[6px] rounded-[2px] shadow-[1px_1px_2px_oklch(0_0_0/0.35)]\"\n                style={{ background: STEEL }}\n              />\n            </motion.div>\n          </div>\n\n          {/* The case: a moulded yellow housing with a rubber side panel\n              over the spring drum, a steel belt clip behind it, and the\n              thumb lock above the mouth. */}\n          <div\n            aria-hidden\n            className=\"absolute top-[14px] -left-[3px] h-[80px] w-[10px] rounded-[4px] shadow-[0_1px_2px_oklch(0_0_0/0.3)]\"\n            style={{ background: STEEL }}\n          />\n          <div\n            className=\"absolute top-0 left-0 h-[120px] rounded-[34px] rounded-br-[12px] shadow-[inset_0_2px_0_oklch(1_0_0/0.45),inset_0_-3px_0_oklch(0_0_0/0.14),0_10px_22px_-8px_oklch(0_0_0/0.4),0_1px_2px_oklch(0_0_0/0.2)]\"\n            style={{ width: CASE_W, background: HOUSING }}\n          >\n            <div\n              aria-hidden\n              className=\"absolute top-[14px] left-[12px] flex size-[84px] items-center justify-center rounded-full shadow-[inset_0_2px_4px_oklch(0_0_0/0.5),0_1px_0_oklch(1_0_0/0.35)]\"\n              style={{ background: RUBBER }}\n            >\n              {/* Grip ridges moulded into the rubber. */}\n              <span\n                className=\"absolute inset-[6px] rounded-full\"\n                style={{\n                  background:\n                    \"repeating-conic-gradient(oklch(1 0 0 / 0.06) 0deg 4deg, transparent 4deg 12deg)\",\n                }}\n              />\n              <span\n                className=\"relative flex size-[30px] items-center justify-center rounded-full shadow-[0_1px_2px_oklch(0_0_0/0.5)]\"\n                style={{ background: STEEL }}\n              >\n                <span className=\"size-[7px] rounded-full\" style={{ background: RIVET }} />\n              </span>\n            </div>\n            {/* The mouth the blade feeds out of. */}\n            <div\n              aria-hidden\n              className=\"absolute top-[78px] right-0 h-[32px] w-[5px] rounded-l-[2px]\"\n              style={{ background: RUBBER }}\n            />\n            <button\n              type=\"button\"\n              aria-label=\"Tape lock\"\n              aria-pressed={locked}\n              onClick={toggleLock}\n              className=\"absolute top-[8px] right-[2px] flex h-[64px] w-11 items-start justify-center rounded-full pt-1.5 outline-hidden transition-[scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n            >\n              {/* The slot, and the lock riding in it. Down is locked: the\n                  brake presses on the blade right above the mouth. */}\n              <span\n                className=\"relative h-[48px] w-[14px] rounded-full shadow-[inset_0_1px_3px_oklch(0_0_0/0.6)]\"\n                style={{ background: RUBBER }}\n              >\n                <span\n                  className={cn(\n                    \"absolute top-[1px] left-[1px] flex h-[22px] w-[12px] flex-col items-center justify-center gap-[3px] rounded-full shadow-[inset_0_1px_0_oklch(1_0_0/0.3),0_1px_2px_oklch(0_0_0/0.4)] transition-[translate] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n                    locked && \"translate-y-[24px]\",\n                  )}\n                  style={{ background: LOCK }}\n                >\n                  <span className=\"h-px w-[6px] bg-black/30\" />\n                  <span className=\"h-px w-[6px] bg-black/30\" />\n                  <span className=\"h-px w-[6px] bg-black/30\" />\n                </span>\n              </span>\n            </button>\n          </div>\n        </motion.div>\n      </div>\n\n      <p id={`${id}-hint`} className=\"text-sm text-muted-foreground\">\n        Pull the tab to measure. Release the lock to reel it back in.\n      </p>\n    </div>\n  );\n}\n\n// Thousands of ticks that never change, so they render once.\nconst Blade = memo(function Blade({ max }: { max: number }) {\n  const width = max * SCALE + 1;\n  const ticks: React.ReactNode[] = [];\n  for (let half = 0; half <= max * 2; half++) {\n    const cm = half / 2;\n    // Numbers grow away from the hook, so the scale runs right to left.\n    const x = (max - cm) * SCALE + 0.5;\n    const h = cm % 10 === 0 ? 13 : cm % 5 === 0 ? 10 : Number.isInteger(cm) ? 7 : 4;\n    ticks.push(<line key={half} x1={x} x2={x} y1={0} y2={h} />);\n  }\n  const labels: React.ReactNode[] = [];\n  for (let cm = 10; cm <= max; cm += 10) {\n    labels.push(\n      <text\n        key={cm}\n        x={(max - cm) * SCALE}\n        y={BLADE_H - 3}\n        textAnchor=\"middle\"\n        // Metre marks in red, as printed on a real blade.\n        fill={cm % 100 === 0 ? BLADE_RED : BLADE_INK}\n        className=\"text-xs font-semibold tabular-nums\"\n      >\n        {cm}\n      </text>,\n    );\n  }\n  return (\n    <svg width={width} height={BLADE_H} viewBox={`0 0 ${width} ${BLADE_H}`} aria-hidden className=\"block\">\n      <rect width={width} height={BLADE_H} fill={BLADE} />\n      {/* The blade is cupped: its top edge falls into shadow, its bottom\n          edge catches the light. */}\n      <rect width={width} height={4} fill=\"oklch(0 0 0 / 0.12)\" />\n      <line x1={0} x2={width} y1={BLADE_H - 0.5} y2={BLADE_H - 0.5} stroke=\"oklch(1 0 0 / 0.5)\" />\n      <g stroke={BLADE_INK}>{ticks}</g>\n      {labels}\n    </svg>\n  );\n});\n\nexport default function TapeMeasureDemo() {\n  const [value, setValue] = useState(28);\n  return <TapeMeasure label=\"Shelf depth\" value={value} onChange={setValue} />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"categories":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/tape-measure"}