Steel Tape Measure Range Slider with a Blade That Sags, a Thumb Lock and Spring Rewind

A range input dressed as a yellow pocket tape. The value is the length of blade you have pulled out of the case, and the scale printed on the steel is generated from the same minimum and maximum the input uses, so the number sitting at the mouth of the case is always the reading. The blade is drawn as an unsupported strip: it leaves the case flat, droops further the longer it gets, and bounces at the hook when you stop pulling. A red thumb tab on the shoulder of the case is a real pressed-or-not button. With the tab down the blade stays where you leave it. Slide the tab up and the spring in the case takes the blade back to zero, with a small kick when the hook hits the guard. On phones the case shrinks by about a third and the blade uses whatever width is left.

Code

import { useEffect, useRef, useState } from "react";

// What the tape measures. The blade is printed from these numbers, so change them freely.
const LABEL = "Shelf width";
const UNIT = "cm";
const MIN = 0;
const MAX = 60;
const STEP = 1;
const START = 46;
const DEMO = true; // the tape plays by itself when left alone. Set to false inside a real form.
const SHELL = ["#ffd84a", "#f4b90c", "#d39300"]; // case plastic: lit, body, shaded
const BLADE = ["#fff0a0", "#f6cf3a", "#d9a916"]; // blade lacquer: bright band, body, edge
const TAB = "#e23b2e"; // the thumb lock

const snap = (v: number) => Math.max(MIN, Math.min(MAX, Math.round(v / STEP) * STEP));

export default function SteelTapeMeasureSlider() {
  const canvas = useRef<HTMLCanvasElement>(null);
  const lockBtn = useRef<HTMLButtonElement>(null);
  const [value, setValue] = useState(START);
  const [locked, setLocked] = useState(true);
  const api = useRef<{ set: (v: number) => void; lock: (on: boolean) => void } | null>(null);

  useEffect(() => {
    const cv = canvas.current;
    if (!cv) return;
    const ctx = cv.getContext("2d");
    if (!ctx) return;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    const CW = 172, CH = 164; // the case, before scaling
    let W = 0, H = 0, k = 1, cx0 = 0, cy0 = 0, mx = 0, my = 0, Lmax = 1, ppu = 1, BH = 30;

    // Fine speckle that stops the plastic and rubber looking like flat fills.
    const grain = document.createElement("canvas");
    grain.width = grain.height = 96;
    const gx = grain.getContext("2d");
    if (gx) {
      const im = gx.createImageData(96, 96);
      let seed = 11;
      for (let i = 0; i < 96 * 96; i++) {
        seed = (seed * 16807) % 2147483647;
        const v = seed % 255;
        im.data[i * 4] = im.data[i * 4 + 1] = im.data[i * 4 + 2] = v > 127 ? 255 : 0;
        im.data[i * 4 + 3] = Math.abs(v - 127) * 0.2;
      }
      gx.putImageData(im, 0, 0);
    }
    const speckle = ctx.createPattern(grain, "repeat");

    const s = { u: DEMO ? START - 8 : START, uv: 0, target: DEMO ? START - 8 : START, raw: START, drag: false, held: false, locked: true, lockPos: 1, wob: 0, wobV: 0, kick: 0, kickV: 0, shown: START, lockShown: true, keyed: false };

    const layout = () => {
      W = cv.clientWidth; H = cv.clientHeight;
      const small = W < 560;
      k = small ? 0.8 : W >= 760 ? 1.42 : 1;
      cx0 = small ? 2 : 30; cy0 = H * 0.43 - CH * k * 0.66;
      mx = cx0 + (CW + 2) * k; my = cy0 + (CH - 25) * k; BH = 30 * k;
      Lmax = W - mx - (small ? 14 : 58); ppu = Lmax / (MAX - MIN);
      cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
      const b = lockBtn.current;
      if (b) { b.style.left = `${cx0 + (CW - 24) * k - 24}px`; b.style.top = `${cy0 + 55 * k - 32}px`; }
    };

    const rr = (x: number, y: number, w: number, h: number, r: number[]) => {
      ctx.beginPath(); ctx.moveTo(x + r[0], y);
      ctx.arcTo(x + w, y, x + w, y + h, r[1]); ctx.arcTo(x + w, y + h, x, y + h, r[2]);
      ctx.arcTo(x, y + h, x, y, r[3]); ctx.arcTo(x, y, x + w, y, r[0]); ctx.closePath();
    };
    const OUT = [50, 44, 20, 30];

    // The unsupported blade droops like a beam fixed at one end: flat at the mouth, steepest at the hook.
    const curve = (L: number, t: number) => {
      const n = L / Lmax;
      const def = 0.17 * Lmax * Math.pow(n, 2.2) + (s.wob + Math.sin(t / 1000) * 1.8) * n * n;
      const y = (x: number) => { const q = Math.max(0, x / Math.max(L, 1)); return (def * q * q * (6 - 4 * q + q * q)) / 3; };
      const slope = (x: number) => { const q = Math.max(0, x / Math.max(L, 1)); return (def * (12 * q - 12 * q * q + 4 * q * q * q)) / 3 / Math.max(L, 1); };
      return { y, slope };
    };

    const drawBlade = (L: number, c: ReturnType<typeof curve>) => {
      const SEG = 3, h = BH;
      const g = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
      g.addColorStop(0, "#8f6c05"); g.addColorStop(0.05, BLADE[2]); g.addColorStop(0.2, BLADE[1]); g.addColorStop(0.36, BLADE[0]);
      g.addColorStop(0.5, BLADE[1]); g.addColorStop(0.82, BLADE[2]); g.addColorStop(0.9, "#a47d09"); g.addColorStop(0.93, "#6b5206"); g.addColorStop(1, "#3d2f04");
      for (let x = -6; x < L; x += SEG) {
        ctx.save(); ctx.translate(mx + x, my + c.y(x)); ctx.rotate(Math.atan(c.slope(x)));
        ctx.fillStyle = g; ctx.fillRect(0, -h / 2, Math.min(SEG, L - x) + 0.7, h);
        ctx.restore();
      }
      // Printed scale. Marks are measured from the hook, so the number at the mouth is the reading.
      const sub = ppu >= 9 ? 5 : ppu >= 5 ? 2 : 1;
      const top = -h / 2 + h * 0.05, face = h * 0.86;
      const count = Math.ceil(L / ppu) + 1;
      ctx.textAlign = "center"; ctx.textBaseline = "middle";
      for (let i = 0; i <= count * sub; i++) {
        const d = i / sub, x = L - d * ppu;
        if (x < -2 || d > MAX - MIN + 0.01) continue;
        const whole = i % sub === 0, n = Math.round(d), ten = whole && n % 10 === 0, five = whole && n % 5 === 0;
        ctx.save(); ctx.translate(mx + x, my + c.y(x)); ctx.rotate(Math.atan(c.slope(x)));
        const len = ten ? face * 0.36 : five ? face * 0.44 : whole ? face * 0.32 : face * 0.17;
        ctx.fillStyle = whole ? "rgba(24,20,8,0.92)" : "rgba(24,20,8,0.7)";
        ctx.fillRect(-(whole ? 0.6 : 0.35), top, whole ? 1.2 : 0.7, len);
        if (!whole) ctx.fillRect(-0.35, top + face - len * 0.8, 0.7, len * 0.8);
        if (n > 0 && ten) {
          ctx.font = `800 ${h * 0.4}px ui-sans-serif, system-ui, Helvetica, Arial, sans-serif`;
          ctx.fillStyle = "#b3190f"; ctx.fillText(String(n + MIN), 0, h * 0.14);
        } else if (n > 0 && whole && (five || ppu >= 14)) {
          ctx.font = `700 ${h * (five ? 0.29 : 0.24)}px ui-sans-serif, system-ui, Helvetica, Arial, sans-serif`;
          ctx.fillStyle = "rgba(24,20,8,0.9)"; ctx.fillText(String(five ? n + MIN : n % 10), 0, h * 0.2);
        }
        ctx.restore();
      }
      // A thin run of light along the curl of the steel, and the shade the case throws on the first inch.
      ctx.lineCap = "round";
      for (const [off, wd, col] of [[-0.19, 1.6, "rgba(255,255,255,0.55)"], [0.39, 0.8, "rgba(255,255,255,0.28)"]] as [number, number, string][]) {
        ctx.beginPath();
        for (let x = 0; x <= L; x += 6) { const a = Math.atan(c.slope(x)); ctx.lineTo(mx + x - Math.sin(a) * h * off, my + c.y(x) + Math.cos(a) * h * off); }
        ctx.lineWidth = wd * k; ctx.strokeStyle = col; ctx.stroke();
      }
      const shade = ctx.createLinearGradient(mx, 0, mx + 46 * k, 0);
      shade.addColorStop(0, "rgba(20,12,0,0.5)"); shade.addColorStop(1, "rgba(20,12,0,0)");
      ctx.fillStyle = shade; ctx.fillRect(mx, my - h / 2 - 1, Math.min(L, 46 * k), h + 4);
    };

    const drawHook = (L: number, c: ReturnType<typeof curve>) => {
      const h = BH;
      ctx.save(); ctx.translate(mx + L, my + c.y(L)); ctx.rotate(Math.atan(c.slope(L)));
      const steel = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
      steel.addColorStop(0, "#f4f5f6"); steel.addColorStop(0.35, "#c9cdd2"); steel.addColorStop(0.7, "#9097a0"); steel.addColorStop(1, "#5c626a");
      // The riveted tongue on top of the blade.
      rr(-24 * k, -h / 2 + h * 0.16, 25 * k, h * 0.64, [3 * k, 0, 0, 3 * k]); ctx.fillStyle = steel; ctx.fill();
      ctx.lineWidth = 0.7; ctx.strokeStyle = "rgba(40,44,50,0.65)"; ctx.stroke();
      ctx.fillStyle = "rgba(255,255,255,0.7)"; ctx.fillRect(-23 * k, -h / 2 + h * 0.18, 22 * k, 0.9);
      for (const ry of [-0.12, 0.2]) {
        const g = ctx.createRadialGradient(-13 * k - 0.8, h * ry - 0.8, 0.2, -13 * k, h * ry, 3.1 * k);
        g.addColorStop(0, "#ffffff"); g.addColorStop(0.5, "#aab0b8"); g.addColorStop(1, "#4b5058");
        ctx.beginPath(); ctx.arc(-13 * k, h * ry, 3 * k, 0, Math.PI * 2); ctx.fillStyle = g; ctx.fill();
      }
      // The end tab that catches the edge of the work.
      const tab = ctx.createLinearGradient(-1, 0, 6 * k, 0);
      tab.addColorStop(0, "#f7f8f9"); tab.addColorStop(0.3, "#d5d9de"); tab.addColorStop(1, "#6c727b");
      rr(-0.5, -h / 2 - 2 * k, 5.5 * k, h + 17 * k, [1.5, 1.5, 3 * k, 3 * k]); ctx.fillStyle = tab; ctx.fill();
      ctx.lineWidth = 0.7; ctx.strokeStyle = "rgba(40,44,50,0.7)"; ctx.stroke();
      ctx.restore();
    };

    const screw = (x: number, y: number, turn: number) => {
      ctx.beginPath(); ctx.arc(x, y, 6.6, 0, Math.PI * 2); ctx.fillStyle = "rgba(60,36,0,0.5)"; ctx.fill();
      ctx.beginPath(); ctx.arc(x, y + 0.7, 6.3, 0, Math.PI * 2); ctx.fillStyle = "rgba(255,240,180,0.55)"; ctx.fill();
      const g = ctx.createRadialGradient(x - 1.6, y - 2, 0.4, x, y, 5.4);
      g.addColorStop(0, "#fbfbfc"); g.addColorStop(0.55, "#aeb3ba"); g.addColorStop(1, "#555b63");
      ctx.beginPath(); ctx.arc(x, y, 5.3, 0, Math.PI * 2); ctx.fillStyle = g; ctx.fill();
      ctx.save(); ctx.translate(x, y); ctx.rotate(turn); ctx.fillStyle = "rgba(20,22,26,0.82)";
      ctx.fillRect(-3.5, -0.75, 7, 1.5); ctx.fillRect(-0.75, -3.5, 1.5, 7); ctx.restore();
    };

    const drawCase = () => {
      ctx.save(); ctx.translate(cx0 + s.kick, cy0); ctx.scale(k, k);
      // Rubber jacket.
      rr(0, 0, CW, CH, OUT);
      const rub = ctx.createLinearGradient(0, 0, CW * 0.6, CH);
      rub.addColorStop(0, "#3d3e42"); rub.addColorStop(0.35, "#232427"); rub.addColorStop(1, "#0f0f11");
      ctx.fillStyle = rub; ctx.fill();
      if (speckle) { ctx.save(); ctx.clip(); ctx.globalAlpha = 0.5; ctx.fillStyle = speckle; ctx.fillRect(0, 0, CW, CH); ctx.restore(); }
      rr(0.8, 0.8, CW - 1.6, CH - 1.6, OUT);
      const rim = ctx.createLinearGradient(0, 0, CW * 0.5, CH);
      rim.addColorStop(0, "rgba(255,255,255,0.5)"); rim.addColorStop(0.3, "rgba(255,255,255,0.06)"); rim.addColorStop(1, "rgba(0,0,0,0.6)");
      ctx.lineWidth = 1.6; ctx.strokeStyle = rim; ctx.stroke();
      // Grip ribs moulded into the heel.
      for (let i = 0; i < 6; i++) {
        const x = 16 + i * 8.5;
        ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(x, CH - 30, 3.2, 20);
        ctx.fillStyle = "rgba(255,255,255,0.13)"; ctx.fillRect(x + 3.2, CH - 30, 1, 20);
      }
      // The plastic shell that shows through the jacket.
      rr(11, 10, CW - 22, CH - 56, [41, 35, 12, 12]);
      const sh = ctx.createLinearGradient(20, 10, CW - 20, CH - 40);
      sh.addColorStop(0, SHELL[0]); sh.addColorStop(0.45, SHELL[1]); sh.addColorStop(1, SHELL[2]);
      ctx.fillStyle = sh; ctx.fill();
      ctx.save(); ctx.clip();
      if (speckle) { ctx.globalAlpha = 0.22; ctx.fillStyle = speckle; ctx.fillRect(0, 0, CW, CH); ctx.globalAlpha = 1; }
      const gloss = ctx.createRadialGradient(46, 22, 4, 46, 22, 120);
      gloss.addColorStop(0, "rgba(255,255,255,0.55)"); gloss.addColorStop(0.4, "rgba(255,255,255,0.1)"); gloss.addColorStop(1, "rgba(255,255,255,0)");
      ctx.fillStyle = gloss; ctx.fillRect(0, 0, CW, CH);
      const under = ctx.createLinearGradient(0, CH - 80, 0, CH - 46);
      under.addColorStop(0, "rgba(120,60,0,0)"); under.addColorStop(1, "rgba(120,60,0,0.3)");
      ctx.fillStyle = under; ctx.fillRect(0, CH - 80, CW, 40);
      ctx.restore();
      rr(11.8, 10.8, CW - 23.6, CH - 57.6, [40, 34, 11, 11]);
      const bev = ctx.createLinearGradient(11, 10, CW - 30, CH - 40);
      bev.addColorStop(0, "rgba(255,255,255,0.85)"); bev.addColorStop(0.35, "rgba(255,255,255,0.1)"); bev.addColorStop(1, "rgba(90,50,0,0.55)");
      ctx.lineWidth = 1.5; ctx.strokeStyle = bev; ctx.stroke();
      rr(10.4, 9.4, CW - 20.8, CH - 54.8, [41.5, 35.5, 12.5, 12.5]);
      ctx.lineWidth = 1; ctx.strokeStyle = "rgba(0,0,0,0.7)"; ctx.stroke();

      // Centre badge: a spun steel ring around a black label.
      const bx = 72, by = 62;
      ctx.save(); ctx.shadowColor = "rgba(70,40,0,0.5)"; ctx.shadowBlur = 7 * dpr; ctx.shadowOffsetY = 3 * dpr;
      ctx.beginPath(); ctx.arc(bx, by, 37, 0, Math.PI * 2); ctx.fillStyle = "#777"; ctx.fill(); ctx.restore();
      let ring: CanvasGradient;
      if (typeof ctx.createConicGradient === "function") {
        ring = ctx.createConicGradient(-2.3, bx, by);
        for (const [p, v] of [[0, 250], [0.08, 190], [0.2, 96], [0.32, 170], [0.45, 240], [0.5, 205], [0.58, 110], [0.72, 80], [0.85, 160], [0.94, 228], [1, 250]] as [number, number][]) ring.addColorStop(p, `rgb(${v - 3},${v},${v + 4})`);
      } else { ring = ctx.createLinearGradient(bx - 37, by - 37, bx + 37, by + 37); ring.addColorStop(0, "#f4f5f6"); ring.addColorStop(1, "#6a6f77"); }
      ctx.beginPath(); ctx.arc(bx, by, 37, 0, Math.PI * 2); ctx.fillStyle = ring; ctx.fill();
      ctx.lineWidth = 0.8; ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.stroke();
      const disc = ctx.createRadialGradient(bx - 10, by - 14, 2, bx, by, 32);
      disc.addColorStop(0, "#3a3b3f"); disc.addColorStop(0.6, "#18181a"); disc.addColorStop(1, "#0b0b0c");
      ctx.beginPath(); ctx.arc(bx, by, 31, 0, Math.PI * 2); ctx.fillStyle = disc; ctx.fill();
      ctx.lineWidth = 1; ctx.strokeStyle = "rgba(0,0,0,0.85)"; ctx.stroke();
      ctx.beginPath(); ctx.arc(bx, by, 26.5, 0, Math.PI * 2); ctx.lineWidth = 0.7; ctx.strokeStyle = "rgba(244,185,12,0.75)"; ctx.stroke();
      ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = SHELL[1];
      ctx.font = "800 21px ui-sans-serif, system-ui, Helvetica, Arial, sans-serif"; ctx.fillText(String(MAX), bx, by - 3);
      ctx.font = "700 6.5px ui-monospace, SFMono-Regular, Menlo, monospace"; ctx.fillStyle = "rgba(255,255,255,0.72)";
      ctx.fillText(UNIT.toUpperCase() + "  CLASS II", bx, by + 13);
      const glint = ctx.createLinearGradient(bx - 30, by - 30, bx + 10, by + 6);
      glint.addColorStop(0, "rgba(255,255,255,0.2)"); glint.addColorStop(0.5, "rgba(255,255,255,0)");
      ctx.beginPath(); ctx.arc(bx, by, 31, 0, Math.PI * 2); ctx.fillStyle = glint; ctx.fill();

      screw(28, 100, 0.5); screw(132, 100, 1.2); screw(120, 22, 0.2);

      // Steel guard where the blade leaves the case.
      const gd = ctx.createLinearGradient(CW - 15, 0, CW + 3, 0);
      gd.addColorStop(0, "#8d939b"); gd.addColorStop(0.35, "#eef0f2"); gd.addColorStop(0.7, "#b9bec5"); gd.addColorStop(1, "#5d636b");
      rr(CW - 15, CH - 45, 18, 41, [2, 2, 2, 2]); ctx.fillStyle = gd; ctx.fill();
      ctx.lineWidth = 0.8; ctx.strokeStyle = "rgba(0,0,0,0.6)"; ctx.stroke();
      ctx.fillStyle = "#0a0a0b"; ctx.fillRect(CW - 0.5, CH - 41, 3.5, 33);

      // Thumb lock: a slot in the shoulder and a ribbed tab that slides down to pinch the blade.
      rr(CW - 31, 31, 14, 48, [7, 7, 7, 7]); ctx.fillStyle = "#070708"; ctx.fill();
      ctx.lineWidth = 1; ctx.strokeStyle = "rgba(255,255,255,0.16)"; ctx.stroke();
      const ty = 30 + s.lockPos * 25;
      ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.6)"; ctx.shadowBlur = 5 * dpr; ctx.shadowOffsetY = 2.5 * dpr;
      rr(CW - 37, ty, 43, 25, [6, 9, 9, 6]);
      const tg = ctx.createLinearGradient(0, ty, 0, ty + 25);
      tg.addColorStop(0, "#ff8a7c"); tg.addColorStop(0.16, TAB); tg.addColorStop(0.75, "#b7241a"); tg.addColorStop(1, "#7f150e");
      ctx.fillStyle = tg; ctx.fill(); ctx.restore();
      for (let i = 0; i < 4; i++) {
        ctx.fillStyle = "rgba(70,0,0,0.6)"; ctx.fillRect(CW - 30, ty + 5 + i * 4.4, 28, 1.5);
        ctx.fillStyle = "rgba(255,255,255,0.35)"; ctx.fillRect(CW - 30, ty + 6.5 + i * 4.4, 28, 0.7);
      }
      ctx.font = "700 6px ui-monospace, SFMono-Regular, Menlo, monospace"; ctx.textAlign = "right"; ctx.fillStyle = "rgba(60,36,0,0.8)";
      ctx.fillText("LOCK", CW - 41, 87);
      ctx.beginPath(); ctx.moveTo(CW - 54, 92); ctx.lineTo(CW - 46, 92); ctx.lineTo(CW - 50, 97); ctx.closePath(); ctx.fill();
      ctx.restore();
    };

    const draw = (t: number) => {
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, W, H);
      const L = Math.max(0, (s.u - MIN) * ppu), c = curve(L, t);
      // Shadows on the backdrop a hand's width behind: the case, then the blade.
      ctx.save();
      ctx.shadowColor = "rgba(70,52,20,0.34)"; ctx.shadowBlur = 34 * dpr; ctx.shadowOffsetX = 14 * dpr; ctx.shadowOffsetY = 30 * dpr;
      ctx.translate(cx0 + s.kick, cy0); ctx.scale(k, k); rr(4, 4, CW - 8, CH - 8, OUT); ctx.fillStyle = "#222"; ctx.fill();
      ctx.restore();
      if (L > 2) {
        ctx.save();
        ctx.shadowColor = "rgba(70,52,20,0.3)"; ctx.shadowBlur = 20 * dpr; ctx.shadowOffsetX = 12 * dpr; ctx.shadowOffsetY = 34 * dpr;
        ctx.beginPath(); for (let x = 0; x <= L; x += 8) ctx.lineTo(mx + x, my + c.y(x)); ctx.lineTo(mx + L, my + c.y(L));
        ctx.lineWidth = BH * 0.5; ctx.strokeStyle = "#caa21c"; ctx.stroke();
        ctx.restore();
      }
      drawBlade(L, c);
      drawHook(L, c);
      drawCase();
    };

    let raf = 0, last = 0, t0 = 0, seen = true, touched = -1e9, demoAt = 0, step = 0;
    // The self-running demo: it opens on the finished pull, then shows the lock and the rewind.
    const script: [number, () => void][] = [
      [650, () => { s.target = START; }],
      [4300, () => { s.locked = false; s.held = false; }],
      [5900, () => { s.held = true; s.target = MIN + (MAX - MIN) * 0.38; }],
      [7300, () => { s.locked = true; s.held = false; }],
      [8700, () => { s.target = START; }],
      [12600, () => { demoAt += 8300; step = 1; }],
    ];
    const publish = () => {
      const v = s.keyed ? s.target : snap(s.u); // a key press shows its result at once, the blade catches up
      if (v !== s.shown) { s.shown = v; setValue(v); }
      if (s.locked !== s.lockShown) { s.lockShown = s.locked; setLocked(s.locked); }
    };

    const frame = (now: number) => {
      raf = 0;
      if (!t0) { t0 = now; last = now; demoAt = now; }
      const dt = Math.min(0.033, (now - last) / 1000); last = now;
      if (DEMO && now - touched > 6000) {
        if (touched > 0) { touched = -1e9; s.keyed = false; demoAt = now - 7300; step = 4; s.locked = true; s.held = false; s.target = snap(s.u); }
        while (step < script.length && now - demoAt >= script[step][0]) { const run = script[step][1]; step++; run(); }
      }
      const before = s.uv;
      if (s.drag) {
        const nu = s.u + (s.raw - s.u) * Math.min(1, dt * 28);
        s.uv = (nu - s.u) / Math.max(dt, 0.001); s.u = nu;
      } else if (s.locked || s.held) {
        s.uv += (120 * (s.target - s.u) - 17 * s.uv) * dt; s.u += s.uv * dt;
      } else if (s.u > MIN || s.uv !== 0) {
        // Lock off and nobody holding: the spring in the case wins.
        s.uv += (-26 * (s.u - MIN) - 1.6 * s.uv - (MAX - MIN) * 4.2) * dt; s.u += s.uv * dt;
        if (s.u <= MIN) { s.kickV += s.uv * ppu * 0.05; s.wobV -= 60; s.u = MIN; s.uv = Math.abs(s.uv) > (MAX - MIN) * 0.5 ? -s.uv * 0.16 : 0; s.target = MIN; }
      }
      s.wobV += (-150 * s.wob - 7 * s.wobV) * dt + (s.uv - before) * ppu * 0.05;
      s.wob = Math.max(-46, Math.min(46, s.wob + s.wobV * dt));
      s.kickV += (-700 * s.kick - 30 * s.kickV) * dt; s.kick += s.kickV * dt;
      s.lockPos += ((s.locked ? 1 : 0) - s.lockPos) * Math.min(1, dt * 22);
      publish();
      draw(now - t0);
      if (seen && !document.hidden) raf = requestAnimationFrame(frame);
    };
    const wake = () => { if (!raf && !reduce) { last = performance.now(); raf = requestAnimationFrame(frame); } };
    const still = () => {
      if (!s.locked && !s.drag && !s.held) s.target = MIN;
      if (!s.drag) s.u = s.target;
      s.uv = 0; s.wob = 0; s.kick = 0; s.lockPos = s.locked ? 1 : 0; publish(); draw(0);
    };
    const refresh = () => { if (reduce) still(); else wake(); };

    api.current = {
      // Arrow keys always engage the lock, so a keyboard user never watches a value rewind.
      set: (v) => { touched = performance.now(); s.locked = true; s.held = false; s.keyed = true; s.target = snap(v); publish(); refresh(); },
      lock: (on) => { touched = performance.now(); s.locked = on; s.held = false; s.keyed = false; if (on) s.target = snap(s.u); refresh(); },
    };

    layout();
    if (reduce) { s.u = s.target = START; still(); } else wake();

    let startX = 0, startU = 0;
    const onDown = (e: PointerEvent) => {
      touched = performance.now(); s.drag = true; s.keyed = false; startX = e.clientX; startU = s.u; s.raw = s.u;
      cv.setPointerCapture(e.pointerId); wake();
    };
    const onMove = (e: PointerEvent) => {
      if (!s.drag) return;
      touched = performance.now();
      let u = startU + (e.clientX - startX) / ppu;
      if (u > MAX) u = MAX + (u - MAX) * 0.12; // the blade is riveted to the drum, so the end gives only a little
      if (u < MIN) u = MIN;
      s.raw = u;
      if (reduce) { s.u = u; still(); }
    };
    const onUp = () => {
      if (!s.drag) return;
      s.drag = false; touched = performance.now(); s.target = snap(s.u);
      if (reduce) still();
    };
    cv.addEventListener("pointerdown", onDown);
    cv.addEventListener("pointermove", onMove);
    cv.addEventListener("pointerup", onUp);
    cv.addEventListener("pointercancel", onUp);
    const ro = new ResizeObserver(() => { layout(); if (reduce) still(); else { draw(performance.now() - t0); wake(); } });
    ro.observe(cv);
    const io = new IntersectionObserver(([entry]) => { seen = entry.isIntersecting; if (seen) wake(); });
    io.observe(cv);
    const onVis = () => { if (!document.hidden) wake(); };
    document.addEventListener("visibilitychange", onVis);
    return () => {
      cancelAnimationFrame(raf); api.current = null;
      cv.removeEventListener("pointerdown", onDown); cv.removeEventListener("pointermove", onMove);
      cv.removeEventListener("pointerup", onUp); cv.removeEventListener("pointercancel", onUp);
      ro.disconnect(); io.disconnect(); document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  return (
    <div className="flex min-h-[620px] w-full flex-col items-center justify-center bg-[#e9e3d6] bg-[radial-gradient(90%_70%_at_38%_30%,#f6f1e7_0%,#e9e3d6_55%,#dcd4c3_100%)] px-3 py-10 sm:min-h-[740px] sm:px-8">
      <div className="relative w-full max-w-[920px]">
        <canvas ref={canvas} aria-hidden="true" className="block h-[300px] w-full cursor-grab touch-pan-y select-none active:cursor-grabbing sm:h-[470px]" />
        <button
          ref={lockBtn}
          type="button"
          aria-pressed={locked}
          aria-label="Blade lock"
          onClick={() => api.current?.lock(!locked)}
          className="absolute h-16 w-12 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#f4b90c]"
        />
      </div>
      <div className="flex w-full max-w-[920px] items-end justify-between gap-4 px-2 sm:px-9">
        <div>
          <input
            id="tape-measure-range"
            type="range"
            min={MIN}
            max={MAX}
            step={STEP}
            value={value}
            aria-valuetext={`${value} ${UNIT}`}
            onChange={(e) => api.current?.set(Number(e.target.value))}
            className="peer sr-only"
          />
          <label htmlFor="tape-measure-range" className="block rounded-md px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.2em] text-stone-600 peer-focus-visible:ring-2 peer-focus-visible:ring-stone-900">
            {LABEL}
          </label>
          <output htmlFor="tape-measure-range" className="mt-1 block px-1 text-5xl font-semibold tabular-nums tracking-tight text-stone-900 sm:text-6xl">
            {value}
            <span className="ml-2 text-xl font-medium text-stone-500 sm:text-2xl">{UNIT}</span>
          </output>
        </div>
        <p aria-live="polite" className="max-w-[190px] pb-2 text-right font-mono text-[11px] uppercase leading-5 tracking-[0.14em] text-stone-600">
          <span className="text-stone-900">{locked ? "Lock on" : "Lock off"}</span>
          <br />
          {locked ? "Pull the blade or use the arrow keys" : "Let go and it rewinds to zero"}
        </p>
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/steel-tape-measure-range-slider-with-a-blade-that-sags-a-thumb-lock-and-spring-rewind.json

When to use it

How to customize it

Accessibility

Questions

Why does the blade curve down instead of staying straight?

A real blade pulled into the air is a beam held at one end. Its droop follows a known curve that is flat at the support and steepest at the free end, and the amount grows quickly with length. The component uses that curve, then draws the blade as short slices rotated to follow it, so the printed marks tilt with the steel.

What does the lock do to the value?

With the lock on, the value is whatever length you set and it stays. With the lock off, a pointer can still pull the blade out, but on release the length and the value both run back to the minimum. That makes the tab a physical reset.

How do I get the number into a form?

The range input is a normal form field. Give it a `name` and it submits with the form. To use the value elsewhere in React, lift the `value` state up or add an `onChange` prop that is called from the `publish` function.

Is the scale accurate or decorative?

It is accurate. Marks are placed by distance from the hook using the same pixels-per-unit figure that converts a drag into a value, so the mark level with the steel guard matches the readout under the case.

Can the blade point the other way for a right-to-left layout?

The drawing has no mirrored mode built in. The quickest route is `-scale-x-100` on the canvas wrapper, then flip the sign of the drag distance in `onMove`. The printed numbers will be mirrored too, so for a proper version draw the text with a second flip.

The blade moves by itself after a few seconds. How do I stop that?

That is the gallery demo: six seconds after the last touch it pulls, unlocks and rewinds to show what the piece does. Set `DEMO` to false at the top of the file and the blade opens at `START`, stays there, and only ever moves for the person using it.

Related: Lathe-Turned Metal Dial Switch with Detents and Light That Stays Put, Motorised Mixing Desk Fader with LED Meter and Engraved Decibel Scale, Spirit Level Balance Indicator with a Bubble Vial That Settles Between Two Values.

More inputs or all free UI components. Start for free to have a full site built around it.

More in Inputs

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap