Kitchen scale

Up to 5 kg

No dependencies
Code format
React + Tailwind
import { useId, useState } from 'react';

const css = `
  @keyframes field-scale-strain { 0%, 100% { transform: rotate(120deg); } 50% { transform: rotate(124deg); } }
  .field-scale { width: 220px; }
  .field-scale__art { display: block; width: 200px; height: 128px; margin: 0 auto 10px; overflow: visible; }
  .field-scale__body { fill: light-dark(#cbced3, #393c41); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; stroke-linejoin: round; }
  .field-scale__foot { fill: light-dark(#dbdde1, #2e3035); }
  .field-scale__face { fill: light-dark(#fbfcfd, #e5e6e9); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; }
  .field-scale__tick { stroke: light-dark(#5f626a, #393c41); stroke-width: 1; }
  .field-scale__tick--major { stroke-width: 1.8; }
  .field-scale__num { fill: light-dark(#50535b, #2e3035); font: 600 7px system-ui, sans-serif; text-anchor: middle; dominant-baseline: central; }
  /* The needle swings past and settles; the plate sinks under the load on a spring. */
  .field-scale__needle { stroke: light-dark(#373a40, #232529); stroke-width: 2; stroke-linecap: round; transform-box: view-box; transform-origin: 100px 92px; transition: transform 0.7s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .field-scale[data-over] .field-scale__needle { animation: field-scale-strain 0.12s linear infinite; }
  .field-scale__pin { fill: light-dark(#373a40, #232529); }
  .field-scale__load { transition: transform 0.5s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .field-scale__stem { fill: light-dark(#6d7179, #7d8087); }
  .field-scale__plate { fill: light-dark(#5f626a, #a7abb2); }
  .field-scale__parcel { transform-box: view-box; transform-origin: 100px 40px; transition: transform 0.45s cubic-bezier(0.3, 1.5, 0.5, 1); }
  .field-scale__box { fill: light-dark(#6d7179, #7d8087); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; }
  .field-scale__tape { fill: light-dark(#50535b, #c6c8cd); }
  .field-scale__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
  .field-scale__field { display: flex; align-items: center; height: 44px; padding: 0 14px; border: 1px solid light-dark(#dbdde1, #2e3035); border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); color: light-dark(#5f626a, #a7abb2); transition: border-color 0.2s; }
  .field-scale__field:focus-within { border-color: light-dark(#5f626a, #a7abb2); }
  .field-scale__input { flex: 1; min-width: 0; margin: 0; padding: 0; border: 0; outline: none; background: transparent; color: light-dark(#373a40, #f1f2f3); font: 600 18px system-ui, sans-serif; font-variant-numeric: tabular-nums; }
  .field-scale__input::placeholder { color: light-dark(#6d7179, #7d8087); }
  .field-scale__unit { font-size: 15px; }
  .field-scale__note { min-height: 18px; margin: 8px 0 0; color: light-dark(#5f626a, #a7abb2); font-size: 13px; }
  @media (prefers-reduced-motion: reduce) {
    .field-scale__needle, .field-scale__load, .field-scale__parcel { transition: none; }
    .field-scale[data-over] .field-scale__needle { animation: none; }
  }
`;

const MAX = 5000;
const TICKS = Array.from({ length: 21 }, (_, i) => -120 + i * 12);
const pt = (deg: number, r: number) => {
  const a = ((deg - 90) * Math.PI) / 180;
  return [+(100 + r * Math.cos(a)).toFixed(2), +(92 + r * Math.sin(a)).toFixed(2)];
};

export function ScaleInput() {
  const id = useId();
  const [v, setV] = useState('');
  const grams = Number(v) || 0;
  const f = Math.min(grams, MAX) / MAX;
  const over = grams > MAX;
  return (
    <>
      <style>
        {css}
      </style>
      <div className="field-scale" data-over={over || undefined}>
        <svg className="field-scale__art" viewBox="0 0 200 128" aria-hidden="true">
          <g className="field-scale__load" style={{ transform: `translateY(${(f * 7).toFixed(2)}px)` }}>
            <g className="field-scale__parcel" style={{ transform: grams ? `scale(${(0.45 + 0.55 * f).toFixed(3)})` : 'scale(0)' }}>
              <rect className="field-scale__box" x="68" y="2" width="64" height="38" rx="2" />
              <rect className="field-scale__tape" x="96" y="2" width="8" height="38" />
              <rect className="field-scale__tape" x="76" y="10" width="14" height="8" rx="1" />
            </g>
            <rect className="field-scale__stem" x="93" y="44" width="14" height="18" />
            <rect className="field-scale__plate" x="38" y="40" width="124" height="7" rx="3.5" />
          </g>
          <path className="field-scale__body" d="M30 122L40 66Q42 58 50 58H150Q158 58 160 66L170 122Z" />
          <rect className="field-scale__foot" x="38" y="121" width="22" height="5" rx="2" />
          <rect className="field-scale__foot" x="140" y="121" width="22" height="5" rx="2" />
          <circle className="field-scale__face" cx="100" cy="92" r="25" />
          {TICKS.map((t, i) => {
            const [x1, y1] = pt(t, i % 4 ? 20 : 18);
            const [x2, y2] = pt(t, 22.5);
            return <line key={t} className={`field-scale__tick${i % 4 ? '' : ' field-scale__tick--major'}`} x1={x1} y1={y1} x2={x2} y2={y2} />;
          })}
          {[0, 1, 2, 3, 4, 5].map((k) => {
            const [x, y] = pt(-120 + k * 48, 13);
            return (
              <text key={k} className="field-scale__num" x={x} y={y}>
                {k}
              </text>
            );
          })}
          <line className="field-scale__needle" x1="100" y1="96" x2="100" y2="72" style={{ transform: over ? undefined : `rotate(${(-120 + 240 * f).toFixed(1)}deg)` }} />
          <circle className="field-scale__pin" cx="100" cy="92" r="2.4" />
        </svg>
        <label className="field-scale__label" htmlFor={id}>
          Parcel weight (grams)
        </label>
        <div className="field-scale__field">
          <input
            id={id}
            className="field-scale__input"
            value={v}
            inputMode="numeric"
            placeholder="0"
            autoComplete="off"
            aria-describedby={`${id}-note`}
            onChange={(e) =>
              setV(
                e.target.value
                  .replace(/\D/g, '')
                  .replace(/^0+(?=\d)/, '')
                  .slice(0, 5),
              )
            }
          />
          <span className="field-scale__unit" aria-hidden="true">
            g
          </span>
        </div>
        <p className="field-scale__note" id={`${id}-note`} role="status">
          {over ? 'Over 5 kg: send it as freight.' : 'Up to 5 kg'}
        </p>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-scale
Source on GitHub

About this component

A parcel weight field on a kitchen scale: the plate sinks and the needle swings round to the weight you type, a parcel on top grows with it, and past 5 kg the needle strains at the stop.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, spring
Techniques
css transition, svg, transform only
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A real input with inputmode="numeric" and a visible <label> that names the unit; the note is a role="status" line linked with aria-describedby. Under prefers-reduced-motion the needle and plate move at once.

More inputs

See all
Skywriter
Card number
Type a 24-hour time
Dial time
0 of 5 syllables
0 of 7 syllables
0 of 5 syllables
Haiku