Convert
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes field-convert-in { from { opacity: 0; transform: translateY(6px); } }
.field-convert { width: 250px; }
.field-convert__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-convert__input {
width: 100%;
height: 42px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 15px;
transition: box-shadow 0.2s;
}
.field-convert__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-convert__rows { display: grid; gap: 4px; min-height: 84px; margin-top: 8px; }
.field-convert__row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 5px 10px;
border-radius: 7px;
background: light-dark(#f3f4f7, #1a1c1f);
animation: field-convert-in 0.3s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.field-convert__row b { color: light-dark(#373a40, #f1f2f3); font: 600 14px ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
.field-convert__row span { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
@media (prefers-reduced-motion: reduce) { .field-convert__row { animation: none; } }
.field-convert__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-convert__top > :first-child { margin: 0; }
.field-convert__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-convert__info svg { width: 14px; height: 14px; }
.field-convert__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-convert__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-convert__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-convert__info:hover::after, .field-convert__info:focus-visible::after { opacity: 1; transform: none; }
`;
type Unit = { group: 'length' | 'mass' | 'temp'; name: string; toBase: (v: number) => number; fromBase: (v: number) => number };
const lin = (group: Unit['group'], name: string, k: number): Unit => ({ group, name, toBase: (v) => v * k, fromBase: (v) => v / k });
const UNITS: Record<string, Unit> = {
km: lin('length', 'kilometres', 1000),
m: lin('length', 'metres', 1),
mi: lin('length', 'miles', 1609.344),
ft: lin('length', 'feet', 0.3048),
kg: lin('mass', 'kilograms', 1),
g: lin('mass', 'grams', 0.001),
lb: lin('mass', 'pounds', 0.45359237),
c: { group: 'temp', name: 'Celsius', toBase: (v) => v, fromBase: (v) => v },
f: { group: 'temp', name: 'Fahrenheit', toBase: (v) => ((v - 32) * 5) / 9, fromBase: (v) => (v * 9) / 5 + 32 },
};
const SYMBOL: Record<string, string> = { c: '°C', f: '°F' };
/** A number that eases to its new value instead of jumping. */
function useTween(target: number) {
const [v, setV] = useState(target);
const from = useRef(target);
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setV(target);
const start = performance.now();
const a = from.current;
let raf = 0;
const step = (now: number) => {
const t = Math.min(1, (now - start) / 450);
const k = 1 - (1 - t) ** 3;
const x = a + (target - a) * k;
from.current = x;
setV(x);
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [target]);
return v;
}
function Row({ value, unit, i }: { value: number; unit: string; i: number }) {
const v = useTween(value);
return (
<div className="field-convert__row" style={{ animationDelay: `${i * 50}ms` }}>
<b>
{v.toLocaleString('en-GB', { maximumFractionDigits: Math.abs(v) < 10 ? 2 : 1 })} {SYMBOL[unit] ?? unit}
</b>
<span>{UNITS[unit].name}</span>
</div>
);
}
export function ConvertInput() {
const [value, setValue] = useState('5 km');
const m = /^\s*(-?\d[\d,]*\.?\d*)\s*°?\s*(km|mi|m|ft|kg|g|lb|c|f)\s*$/i.exec(value);
const unit = m?.[2].toLowerCase();
const n = m ? parseFloat(m[1].replace(/,/g, '')) : NaN;
const others = unit ? Object.keys(UNITS).filter((u) => u !== unit && UNITS[u].group === UNITS[unit].group) : [];
return (
<>
<style>
{css}
</style>
<div className="field-convert">
<div className="field-convert__top">
<label className="field-convert__label" htmlFor="field-convert-input">
Convert
</label>
<button type="button" className="field-convert__info" aria-label="Try 12 mi, 70 kg or 21 °C" data-tip="Try 12 mi, 70 kg or 21 °C">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 7.5v.01" />
</svg>
</button>
</div>
<input
id="field-convert-input"
className="field-convert__input"
value={value}
autoComplete="off"
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
/>
<div className="field-convert__rows" aria-live="polite">
{unit && Number.isFinite(n) ? (
others.map((u, i) => <Row key={`${UNITS[unit].group}-${u}`} i={i} unit={u} value={UNITS[u].fromBase(UNITS[unit].toBase(n))} />)
) : (
null
)}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldConvert(el) on the mounted element instead.
<div class="field-convert">
<div class="field-convert__top">
<label class="field-convert__label" for="field-convert-input">Convert</label>
<button type="button" class="field-convert__info" aria-label="Try 12 mi, 70 kg or 21 °C" data-tip="Try 12 mi, 70 kg or 21 °C">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 11v5M12 7.5v.01"></path>
</svg>
</button>
</div>
<input id="field-convert-input" class="field-convert__input" autoComplete="off" spellCheck="false" value="5 km"/>
<div class="field-convert__rows" aria-live="polite">
<div class="field-convert__row" style="animation-delay:0ms">
<b>5,000 m</b>
<span>metres</span>
</div>
<div class="field-convert__row" style="animation-delay:50ms">
<b>3.11 mi</b>
<span>miles</span>
</div>
<div class="field-convert__row" style="animation-delay:100ms">
<b>16,404.2 ft</b>
<span>feet</span>
</div>
</div>
</div>@keyframes field-convert-in { from { opacity: 0; transform: translateY(6px); } }
.field-convert { width: 250px; }
.field-convert__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-convert__input {
width: 100%;
height: 42px;
padding: 0 12px;
border: 0;
outline: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit; font-size: 15px;
transition: box-shadow 0.2s;
}
.field-convert__input:focus { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-convert__rows { display: grid; gap: 4px; min-height: 84px; margin-top: 8px; }
.field-convert__row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 5px 10px;
border-radius: 7px;
background: light-dark(#f3f4f7, #1a1c1f);
animation: field-convert-in 0.3s cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
.field-convert__row b { color: light-dark(#373a40, #f1f2f3); font: 600 14px ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
.field-convert__row span { color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
@media (prefers-reduced-motion: reduce) { .field-convert__row { animation: none; } }
.field-convert__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-convert__top > :first-child { margin: 0; }
.field-convert__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-convert__info svg { width: 14px; height: 14px; }
.field-convert__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-convert__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-convert__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-convert__info:hover::after, .field-convert__info:focus-visible::after { opacity: 1; transform: none; }function initFieldConvert(root) {
var input = root.querySelector('.field-convert__input');
var rows = root.querySelector('.field-convert__rows');
if (!input || !rows) return;
function lin(group, name, k) {
return { group: group, name: name, toBase: function (v) { return v * k; }, fromBase: function (v) { return v / k; } };
}
var UNITS = {
km: lin('length', 'kilometres', 1000),
m: lin('length', 'metres', 1),
mi: lin('length', 'miles', 1609.344),
ft: lin('length', 'feet', 0.3048),
kg: lin('mass', 'kilograms', 1),
g: lin('mass', 'grams', 0.001),
lb: lin('mass', 'pounds', 0.45359237),
c: { group: 'temp', name: 'Celsius', toBase: function (v) { return v; }, fromBase: function (v) { return v; } },
f: { group: 'temp', name: 'Fahrenheit', toBase: function (v) { return ((v - 32) * 5) / 9; }, fromBase: function (v) { return (v * 9) / 5 + 32; } },
};
var SYMBOL = { c: '°C', f: '°F' };
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var group = null;
var shown = {};
var raf = 0;
function fmt(v, unit) {
return v.toLocaleString('en-GB', { maximumFractionDigits: Math.abs(v) < 10 ? 2 : 1 }) + ' ' + (SYMBOL[unit] || unit);
}
// Each value eases to its new number instead of jumping.
function tween(targets) {
cancelAnimationFrame(raf);
var from = {};
Object.keys(targets).forEach(function (u) { from[u] = shown[u] === undefined ? targets[u] : shown[u]; });
var start = performance.now();
function step(now) {
var t = still ? 1 : Math.min(1, (now - start) / 450);
var k = 1 - Math.pow(1 - t, 3);
Object.keys(targets).forEach(function (u) {
shown[u] = from[u] + (targets[u] - from[u]) * k;
var b = rows.querySelector('[data-unit="' + u + '"] b');
if (b) b.textContent = fmt(shown[u], u);
});
if (t < 1) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function update() {
var m = /^\s*(-?\d[\d,]*\.?\d*)\s*°?\s*(km|mi|m|ft|kg|g|lb|c|f)\s*$/i.exec(input.value);
var unit = m && m[2].toLowerCase();
var n = m ? parseFloat(m[1].replace(/,/g, '')) : NaN;
if (!unit || !isFinite(n)) {
rows.textContent = '';
group = null;
shown = {};
return;
}
var others = Object.keys(UNITS).filter(function (u) { return u !== unit && UNITS[u].group === UNITS[unit].group; });
var targets = {};
others.forEach(function (u) { targets[u] = UNITS[u].fromBase(UNITS[unit].toBase(n)); });
// A new family of units gets fresh rows, which rise in one after another.
if (UNITS[unit].group !== group || rows.children.length !== others.length || others.some(function (u) { return !rows.querySelector('[data-unit="' + u + '"]'); })) {
group = UNITS[unit].group;
rows.textContent = '';
shown = {};
others.forEach(function (u, i) {
var row = document.createElement('div');
row.className = 'field-convert__row';
row.setAttribute('data-unit', u);
row.style.animationDelay = i * 50 + 'ms';
var b = document.createElement('b');
var name = document.createElement('span');
name.textContent = UNITS[u].name;
row.appendChild(b);
row.appendChild(name);
rows.appendChild(row);
});
}
tween(targets);
}
// The server-rendered rows carry no data-unit: rebuild them once.
rows.textContent = '';
input.addEventListener('input', update);
update();
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.field-convert').forEach(initFieldConvert);Goal: Create a "Convert" input.
Visual behavior: A unit converter in one field: type a value with a unit (km, mi, m, ft, kg, g, lb, °C, °F) and every conversion in that family appears below, each number counting to its new value as you edit.
Technique:
- A regular expression reads the number and unit; each unit knows its family and how to convert to and from a base unit (temperature uses its own formulas).
- Rows for the other units of the family rise in with staggered delays when the family changes.
- Each row eases its number toward the new value over 450ms with a cubic ease-out on requestAnimationFrame. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The results are an aria-live region. Under prefers-reduced-motion values change 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 "Convert" input.
Visual behavior: A unit converter in one field: type a value with a unit (km, mi, m, ft, kg, g, lb, °C, °F) and every conversion in that family appears below, each number counting to its new value as you edit.
Technique:
- A regular expression reads the number and unit; each unit knows its family and how to convert to and from a base unit (temperature uses its own formulas).
- Rows for the other units of the family rise in with staggered delays when the family changes.
- Each row eases its number toward the new value over 450ms with a cubic ease-out on requestAnimationFrame. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The results are an aria-live region. Under prefers-reduced-motion values change 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 unit converter in one field: type a value with a unit (km, mi, m, ft, kg, g, lb, °C, °F) and every conversion in that family appears below, each number counting to its new value as you edit.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, delay stagger
- Techniques
- raf, keyframes, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The results are an aria-live region. Under prefers-reduced-motion values change at once.