Kitchen scale
Up to 5 kg
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldScale(el) on the mounted element instead.
<div class="field-scale">
<svg class="field-scale__art" viewBox="0 0 200 128" aria-hidden="true">
<g class="field-scale__load" style="transform:translateY(0.00px)">
<g class="field-scale__parcel" style="transform:scale(0)">
<rect class="field-scale__box" x="68" y="2" width="64" height="38" rx="2"></rect>
<rect class="field-scale__tape" x="96" y="2" width="8" height="38"></rect>
<rect class="field-scale__tape" x="76" y="10" width="14" height="8" rx="1"></rect>
</g>
<rect class="field-scale__stem" x="93" y="44" width="14" height="18"></rect>
<rect class="field-scale__plate" x="38" y="40" width="124" height="7" rx="3.5"></rect>
</g>
<path class="field-scale__body" d="M30 122L40 66Q42 58 50 58H150Q158 58 160 66L170 122Z"></path>
<rect class="field-scale__foot" x="38" y="121" width="22" height="5" rx="2"></rect>
<rect class="field-scale__foot" x="140" y="121" width="22" height="5" rx="2"></rect>
<circle class="field-scale__face" cx="100" cy="92" r="25"></circle>
<line class="field-scale__tick field-scale__tick--major" x1="84.41" y1="101" x2="80.51" y2="103.25"></line>
<line class="field-scale__tick" x1="80.98" y1="98.18" x2="78.6" y2="98.95"></line>
<line class="field-scale__tick" x1="80.11" y1="94.09" x2="77.62" y2="94.35"></line>
<line class="field-scale__tick" x1="80.11" y1="89.91" x2="77.62" y2="89.65"></line>
<line class="field-scale__tick field-scale__tick--major" x1="82.88" y1="86.44" x2="78.6" y2="85.05"></line>
<line class="field-scale__tick" x1="82.68" y1="82" x2="80.51" y2="80.75"></line>
<line class="field-scale__tick" x1="85.14" y1="78.62" x2="83.28" y2="76.94"></line>
<line class="field-scale__tick" x1="88.24" y1="75.82" x2="86.77" y2="73.8"></line>
<line class="field-scale__tick field-scale__tick--major" x1="92.68" y1="75.56" x2="90.85" y2="71.45"></line>
<line class="field-scale__tick" x1="95.84" y1="72.44" x2="95.32" y2="69.99"></line>
<line class="field-scale__tick" x1="100" y1="72" x2="100" y2="69.5"></line>
<line class="field-scale__tick" x1="104.16" y1="72.44" x2="104.68" y2="69.99"></line>
<line class="field-scale__tick field-scale__tick--major" x1="107.32" y1="75.56" x2="109.15" y2="71.45"></line>
<line class="field-scale__tick" x1="111.76" y1="75.82" x2="113.23" y2="73.8"></line>
<line class="field-scale__tick" x1="114.86" y1="78.62" x2="116.72" y2="76.94"></line>
<line class="field-scale__tick" x1="117.32" y1="82" x2="119.49" y2="80.75"></line>
<line class="field-scale__tick field-scale__tick--major" x1="117.12" y1="86.44" x2="121.4" y2="85.05"></line>
<line class="field-scale__tick" x1="119.89" y1="89.91" x2="122.38" y2="89.65"></line>
<line class="field-scale__tick" x1="119.89" y1="94.09" x2="122.38" y2="94.35"></line>
<line class="field-scale__tick" x1="119.02" y1="98.18" x2="121.4" y2="98.95"></line>
<line class="field-scale__tick field-scale__tick--major" x1="115.59" y1="101" x2="119.49" y2="103.25"></line>
<text class="field-scale__num" x="88.74" y="98.5">0</text>
<text class="field-scale__num" x="87.64" y="87.98">1</text>
<text class="field-scale__num" x="94.71" y="80.12">2</text>
<text class="field-scale__num" x="105.29" y="80.12">3</text>
<text class="field-scale__num" x="112.36" y="87.98">4</text>
<text class="field-scale__num" x="111.26" y="98.5">5</text>
<line class="field-scale__needle" x1="100" y1="96" x2="100" y2="72" style="transform:rotate(-120.0deg)"></line>
<circle class="field-scale__pin" cx="100" cy="92" r="2.4"></circle>
</svg>
<label class="field-scale__label" for="field-scale">Parcel weight (grams)</label>
<div class="field-scale__field">
<input id="field-scale" class="field-scale__input" inputMode="numeric" placeholder="0" autoComplete="off" aria-describedby="field-scale-note" value=""/>
<span class="field-scale__unit" aria-hidden="true">g</span>
</div>
<p class="field-scale__note" id="field-scale-note" role="status">Up to 5 kg</p>
</div>@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; }
}function initFieldScale(root) {
var MAX = 5000;
var input = root.querySelector('.field-scale__input');
var load = root.querySelector('.field-scale__load');
var parcel = root.querySelector('.field-scale__parcel');
var needle = root.querySelector('.field-scale__needle');
var note = root.querySelector('.field-scale__note');
function onInput() {
var next = input.value
.replace(/\D/g, '')
.replace(/^0+(?=\d)/, '')
.slice(0, 5);
if (input.value !== next) input.value = next;
var grams = Number(next) || 0;
var f = Math.min(grams, MAX) / MAX;
var over = grams > MAX;
// The plate sinks with the load, the parcel grows and the needle swings round; past 5 kg it strains at the stop.
root.toggleAttribute('data-over', over);
load.style.transform = 'translateY(' + (f * 7).toFixed(2) + 'px)';
parcel.style.transform = grams ? 'scale(' + (0.45 + 0.55 * f).toFixed(3) + ')' : 'scale(0)';
needle.style.transform = over ? '' : 'rotate(' + (-120 + 240 * f).toFixed(1) + 'deg)';
note.textContent = over ? 'Over 5 kg: send it as freight.' : 'Up to 5 kg';
}
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-scale').forEach(initFieldScale);Goal: Create a "Kitchen scale" input.
Visual behavior: 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.
Technique:
- The needle angle, the plate drop and the parcel scale are set from the weight, and springy transitions make each overshoot and settle.
- Past the maximum a data-over attribute holds the needle at the stop and plays a fast strain keyframe, and the note under the field says why.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Kitchen scale" input.
Visual behavior: 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.
Technique:
- The needle angle, the plate drop and the parcel scale are set from the weight, and springy transitions make each overshoot and settle.
- Past the maximum a data-over attribute holds the needle at the stop and plays a fast strain keyframe, and the note under the field says why.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.