Calculator Unit Converter
Original · freeunit converter
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/calc-unit-converter.json"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type CatId =
| "length"
| "mass"
| "temp"
| "volume"
| "area"
| "speed"
| "time"
| "data";
type Unit = {
id: string;
name: string;
sym: string;
/** base = value * f + o */
f: number;
o?: number;
};
type Category = {
id: CatId;
label: string;
base: string;
units: readonly Unit[];
defFrom: string;
defTo: string;
};
type Pair = { from: string; to: string };
type Precision = { id: string; label: string; digits: number | null };
const CATEGORIES: readonly Category[] = [
{
id: "length",
label: "Length",
base: "metre",
defFrom: "m",
defTo: "ft",
units: [
{ id: "mm", name: "Millimetres", sym: "mm", f: 0.001 },
{ id: "cm", name: "Centimetres", sym: "cm", f: 0.01 },
{ id: "m", name: "Metres", sym: "m", f: 1 },
{ id: "km", name: "Kilometres", sym: "km", f: 1000 },
{ id: "in", name: "Inches", sym: "in", f: 0.0254 },
{ id: "ft", name: "Feet", sym: "ft", f: 0.3048 },
{ id: "yd", name: "Yards", sym: "yd", f: 0.9144 },
{ id: "mi", name: "Miles", sym: "mi", f: 1609.344 },
{ id: "nmi", name: "Nautical miles", sym: "nmi", f: 1852 },
],
},
{
id: "mass",
label: "Mass",
base: "kilogram",
defFrom: "kg",
defTo: "lb",
units: [
{ id: "mg", name: "Milligrams", sym: "mg", f: 0.000001 },
{ id: "g", name: "Grams", sym: "g", f: 0.001 },
{ id: "kg", name: "Kilograms", sym: "kg", f: 1 },
{ id: "t", name: "Tonnes", sym: "t", f: 1000 },
{ id: "oz", name: "Ounces", sym: "oz", f: 0.028349523125 },
{ id: "lb", name: "Pounds", sym: "lb", f: 0.45359237 },
{ id: "st", name: "Stone", sym: "st", f: 6.35029318 },
{ id: "ton-us", name: "Short tons (US)", sym: "ton", f: 907.18474 },
],
},
{
id: "temp",
label: "Temperature",
base: "degree Celsius",
defFrom: "c",
defTo: "f",
units: [
{ id: "c", name: "Celsius", sym: "°C", f: 1, o: 0 },
{ id: "f", name: "Fahrenheit", sym: "°F", f: 5 / 9, o: -160 / 9 },
{ id: "k", name: "Kelvin", sym: "K", f: 1, o: -273.15 },
{ id: "r", name: "Rankine", sym: "°R", f: 5 / 9, o: -273.15 },
],
},
{
id: "volume",
label: "Volume",
base: "litre",
defFrom: "l",
defTo: "gal-us",
units: [
{ id: "ml", name: "Millilitres", sym: "ml", f: 0.001 },
{ id: "l", name: "Litres", sym: "L", f: 1 },
{ id: "m3", name: "Cubic metres", sym: "m³", f: 1000 },
{ id: "tsp", name: "Teaspoons (US)", sym: "tsp", f: 0.00492892159375 },
{ id: "tbsp", name: "Tablespoons (US)", sym: "tbsp", f: 0.01478676478125 },
{ id: "floz-us", name: "Fluid ounces (US)", sym: "fl oz", f: 0.0295735295625 },
{ id: "cup", name: "Cups (US)", sym: "cup", f: 0.2365882365 },
{ id: "pt-us", name: "Pints (US)", sym: "pt", f: 0.473176473 },
{ id: "gal-us", name: "Gallons (US)", sym: "gal", f: 3.785411784 },
{ id: "gal-uk", name: "Gallons (imperial)", sym: "gal UK", f: 4.54609 },
],
},
{
id: "area",
label: "Area",
base: "square metre",
defFrom: "m2",
defTo: "ft2",
units: [
{ id: "cm2", name: "Square centimetres", sym: "cm²", f: 0.0001 },
{ id: "m2", name: "Square metres", sym: "m²", f: 1 },
{ id: "ha", name: "Hectares", sym: "ha", f: 10000 },
{ id: "km2", name: "Square kilometres", sym: "km²", f: 1000000 },
{ id: "ft2", name: "Square feet", sym: "ft²", f: 0.09290304 },
{ id: "yd2", name: "Square yards", sym: "yd²", f: 0.83612736 },
{ id: "acre", name: "Acres", sym: "ac", f: 4046.8564224 },
{ id: "mi2", name: "Square miles", sym: "mi²", f: 2589988.110336 },
],
},
{
id: "speed",
label: "Speed",
base: "metre per second",
defFrom: "kmh",
defTo: "mph",
units: [
{ id: "ms", name: "Metres per second", sym: "m/s", f: 1 },
{ id: "kmh", name: "Kilometres per hour", sym: "km/h", f: 1 / 3.6 },
{ id: "mph", name: "Miles per hour", sym: "mph", f: 0.44704 },
{ id: "fts", name: "Feet per second", sym: "ft/s", f: 0.3048 },
{ id: "kn", name: "Knots", sym: "kn", f: 0.5144444444444445 },
{ id: "mach", name: "Mach (sea level, 15 °C)", sym: "Ma", f: 340.29 },
],
},
{
id: "time",
label: "Time",
base: "second",
defFrom: "h",
defTo: "min",
units: [
{ id: "ms", name: "Milliseconds", sym: "ms", f: 0.001 },
{ id: "s", name: "Seconds", sym: "s", f: 1 },
{ id: "min", name: "Minutes", sym: "min", f: 60 },
{ id: "h", name: "Hours", sym: "h", f: 3600 },
{ id: "d", name: "Days", sym: "d", f: 86400 },
{ id: "wk", name: "Weeks", sym: "wk", f: 604800 },
{ id: "yr", name: "Years (Julian, 365.25 d)", sym: "yr", f: 31557600 },
],
},
{
id: "data",
label: "Data",
base: "byte",
defFrom: "mb",
defTo: "mib",
units: [
{ id: "b", name: "Bytes", sym: "B", f: 1 },
{ id: "kb", name: "Kilobytes (10³)", sym: "kB", f: 1000 },
{ id: "kib", name: "Kibibytes (2¹⁰)", sym: "KiB", f: 1024 },
{ id: "mb", name: "Megabytes (10⁶)", sym: "MB", f: 1000000 },
{ id: "mib", name: "Mebibytes (2²⁰)", sym: "MiB", f: 1048576 },
{ id: "gb", name: "Gigabytes (10⁹)", sym: "GB", f: 1000000000 },
{ id: "gib", name: "Gibibytes (2³⁰)", sym: "GiB", f: 1073741824 },
{ id: "tb", name: "Terabytes (10¹²)", sym: "TB", f: 1000000000000 },
{ id: "tib", name: "Tebibytes (2⁴⁰)", sym: "TiB", f: 1099511627776 },
],
},
];
const PRECISIONS: readonly Precision[] = [
{ id: "auto", label: "Auto", digits: null },
{ id: "0", label: "0", digits: 0 },
{ id: "2", label: "2", digits: 2 },
{ id: "4", label: "4", digits: 4 },
{ id: "6", label: "6", digits: 6 },
];
function catOf(id: CatId): Category {
for (const c of CATEGORIES) if (c.id === id) return c;
const first = CATEGORIES[0];
if (first === undefined) throw new Error("No categories defined");
return first;
}
function unitOf(cat: Category, id: string): Unit {
for (const u of cat.units) if (u.id === id) return u;
const first = cat.units[0];
if (first === undefined) throw new Error(`Category ${cat.id} has no units`);
return first;
}
function group(raw: string): string {
if (raw.includes("e") || raw.includes("E")) return raw;
const neg = raw.startsWith("-");
const body = neg ? raw.slice(1) : raw;
const dot = body.indexOf(".");
const intPart = dot === -1 ? body : body.slice(0, dot);
const frac = dot === -1 ? null : body.slice(dot + 1);
const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const joined = frac === null ? grouped : `${grouped}.${frac}`;
return neg ? `−${joined}` : joined;
}
function fmt(n: number, digits: number | null): string {
if (!Number.isFinite(n)) return "—";
const abs = Math.abs(n);
if (digits === null) {
const r = Number(n.toPrecision(10));
if (r !== 0 && (Math.abs(r) >= 1e15 || Math.abs(r) < 1e-6)) {
return r.toExponential(4);
}
return group(String(r));
}
if (n !== 0 && (abs >= 1e15 || abs < 1e-6)) return n.toExponential(digits);
return group(n.toFixed(digits));
}
/** Plain (ungrouped, minus-sign-free) string for the clipboard. */
function raw(n: number, digits: number | null): string {
if (!Number.isFinite(n)) return "";
if (digits === null) return String(Number(n.toPrecision(10)));
return n.toFixed(digits);
}
/** result = value * k + c */
function coeffs(from: Unit, to: Unit): { k: number; c: number } {
const o1 = from.o ?? 0;
const o2 = to.o ?? 0;
return { k: from.f / to.f, c: (o1 - o2) / to.f };
}
function convert(value: number, from: Unit, to: Unit): number {
const { k, c } = coeffs(from, to);
return value * k + c;
}
function formula(from: Unit, to: Unit): string {
const { k, c } = coeffs(from, to);
const kStr = fmt(k, null);
const head = k === 1 ? `${to.sym} = ${from.sym}` : `${to.sym} = ${from.sym} × ${kStr}`;
if (c === 0) return head;
const sign = c > 0 ? "+" : "−";
return `${head} ${sign} ${fmt(Math.abs(c), null)}`;
}
function CatIcon({ id }: { id: CatId }) {
const common = {
width: 16,
height: 16,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
switch (id) {
case "length":
return (
<svg {...common}>
<path d="M3 8.5h18v7H3z" />
<path d="M7 8.5v3M11 8.5v4M15 8.5v3M19 8.5v4" />
</svg>
);
case "mass":
return (
<svg {...common}>
<path d="M12 4.5v2.5" />
<path d="M5 7h14" />
<path d="M6.5 7L3.5 14a3 3 0 006 0L6.5 7z" />
<path d="M17.5 7l-3 7a3 3 0 006 0l-3-7z" />
<path d="M9.5 20h5" />
</svg>
);
case "temp":
return (
<svg {...common}>
<path d="M10 13.5V5a2 2 0 114 0v8.5a4.5 4.5 0 11-4 0z" />
<path d="M12 9v6" />
</svg>
);
case "volume":
return (
<svg {...common}>
<path d="M6 3h12l-1.5 17.2a1 1 0 01-1 .8H8.5a1 1 0 01-1-.8L6 3z" />
<path d="M6.8 11h10.4" />
</svg>
);
case "area":
return (
<svg {...common}>
<path d="M4 4h16v16H4z" />
<path d="M4 12h16M12 4v16" />
</svg>
);
case "speed":
return (
<svg {...common}>
<path d="M4 18a8 8 0 1116 0" />
<path d="M12 18l4.5-5" />
</svg>
);
case "time":
return (
<svg {...common}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</svg>
);
case "data":
return (
<svg {...common}>
<ellipse cx="12" cy="6" rx="7.5" ry="3" />
<path d="M4.5 6v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3V6" />
<path d="M4.5 12v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
</svg>
);
}
}
const INITIAL_PAIRS: Record<CatId, Pair> = CATEGORIES.reduce(
(acc, c) => {
acc[c.id] = { from: c.defFrom, to: c.defTo };
return acc;
},
{} as Record<CatId, Pair>,
);
const NUM_RE = /^-?\d*(\.\d*)?$/;
const SELECT_CLASS =
"w-full appearance-none rounded-xl border border-slate-200 bg-white px-3.5 py-3 pr-9 text-sm font-medium text-slate-900 outline-none transition-colors hover:border-slate-300 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-50 dark:hover:border-white/20 dark:focus-visible:ring-offset-zinc-950";
export default function CalcUnitConverter() {
const reduce = useReducedMotion() ?? false;
const [catId, setCatId] = useState<CatId>("length");
const [pairs, setPairs] = useState<Record<CatId, Pair>>(INITIAL_PAIRS);
const [amount, setAmount] = useState<string>("1");
const [precisionId, setPrecisionId] = useState<string>("auto");
const [spins, setSpins] = useState<number>(0);
const [copied, setCopied] = useState<boolean>(false);
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const copyTimer = useRef<number | null>(null);
useEffect(() => {
return () => {
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
};
}, []);
const cat = useMemo(() => catOf(catId), [catId]);
const pair = pairs[catId];
const fromUnit = useMemo(() => unitOf(cat, pair.from), [cat, pair.from]);
const toUnit = useMemo(() => unitOf(cat, pair.to), [cat, pair.to]);
const digits = useMemo(() => {
for (const p of PRECISIONS) if (p.id === precisionId) return p.digits;
return null;
}, [precisionId]);
const parsed = useMemo(() => {
if (amount === "" || amount === "-" || amount === "." || amount === "-.") {
return null;
}
const v = Number.parseFloat(amount);
return Number.isFinite(v) ? v : null;
}, [amount]);
const result = parsed === null ? null : convert(parsed, fromUnit, toUnit);
const resultText = result === null ? "—" : fmt(result, digits);
const resultRaw = result === null ? "" : raw(result, digits);
const setPair = useCallback(
(next: Partial<Pair>) => {
setPairs((prev) => ({ ...prev, [catId]: { ...prev[catId], ...next } }));
setCopied(false);
},
[catId],
);
const onAmountChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
if (v === "" || NUM_RE.test(v)) {
setAmount(v);
setCopied(false);
}
}, []);
const swap = useCallback(() => {
setPairs((prev) => {
const cur = prev[catId];
return { ...prev, [catId]: { from: cur.to, to: cur.from } };
});
setSpins((s) => s + 1);
setCopied(false);
}, [catId]);
const selectCat = useCallback((id: CatId) => {
setCatId(id);
setCopied(false);
}, []);
const onTabKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {
let next = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (i + 1) % CATEGORIES.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (i - 1 + CATEGORIES.length) % CATEGORIES.length;
} else if (e.key === "Home") {
next = 0;
} else if (e.key === "End") {
next = CATEGORIES.length - 1;
} else {
return;
}
const target = CATEGORIES[next];
if (target === undefined) return;
e.preventDefault();
selectCat(target.id);
tabRefs.current[next]?.focus();
},
[selectCat],
);
const copyResult = useCallback(() => {
if (resultRaw === "") return;
const write = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(resultRaw);
setCopied(true);
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
} catch {
setCopied(false);
}
};
void write();
}, [resultRaw]);
const sizeClass =
resultText.length > 16
? "text-2xl sm:text-3xl"
: resultText.length > 11
? "text-3xl sm:text-4xl"
: "text-4xl sm:text-5xl";
const spring = reduce
? { duration: 0 }
: { type: "spring" as const, stiffness: 420, damping: 34 };
return (
<section className="relative w-full bg-white px-4 py-20 text-slate-900 sm:px-6 sm:py-24 lg:py-28 dark:bg-zinc-950 dark:text-zinc-50">
<style>{`
@keyframes calcunit-pop {
0% { transform: translateY(4px) scale(0.985); opacity: 0.35; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
@keyframes calcunit-tick {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes calcunit-sheen {
0% { transform: translateX(-120%); }
100% { transform: translateX(220%); }
}
.calcunit-pop { animation: calcunit-pop 260ms cubic-bezier(0.22, 1, 0.36, 1); }
.calcunit-tick { animation: calcunit-tick 240ms cubic-bezier(0.22, 1, 0.36, 1); }
.calcunit-sheen { animation: calcunit-sheen 2.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.calcunit-pop,
.calcunit-tick,
.calcunit-sheen { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="mb-10 max-w-2xl sm:mb-12">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Calculators
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-zinc-50">
Convert anything, exactly
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
Eight categories built on exact definitions — a mile is 1,609.344 m, a pound is
0.45359237 kg, and a megabyte is not a mebibyte. Pick a category, type a number, read
every unit at once.
</p>
</header>
{/* Category tabs */}
<div
role="tablist"
aria-label="Measurement categories"
aria-orientation="horizontal"
className="-mx-4 mb-8 flex gap-2 overflow-x-auto px-4 pb-2 sm:mx-0 sm:flex-wrap sm:px-0"
>
{CATEGORIES.map((c, i) => {
const active = c.id === catId;
return (
<button
key={c.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
type="button"
role="tab"
id={`calcunit-tab-${c.id}`}
aria-selected={active}
aria-controls="calcunit-panel"
tabIndex={active ? 0 : -1}
onClick={() => selectCat(c.id)}
onKeyDown={(e) => onTabKeyDown(e, i)}
className={`relative isolate flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950 ${
active
? "text-white dark:text-zinc-950"
: "text-slate-600 ring-1 ring-slate-200 hover:bg-slate-50 hover:text-slate-900 dark:text-zinc-400 dark:ring-white/10 dark:hover:bg-zinc-900 dark:hover:text-zinc-100"
}`}
>
{active ? (
<motion.span
layoutId="calcunit-tab-pill"
transition={spring}
className="absolute inset-0 -z-10 rounded-full bg-slate-900 dark:bg-zinc-100"
/>
) : null}
<CatIcon id={c.id} />
{c.label}
</button>
);
})}
</div>
<div
role="tabpanel"
id="calcunit-panel"
aria-labelledby={`calcunit-tab-${catId}`}
tabIndex={0}
className="rounded-3xl outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950"
>
<div className="grid gap-6 lg:grid-cols-5">
{/* Converter */}
<div className="lg:col-span-3">
<div className="rounded-3xl bg-slate-50 p-5 ring-1 ring-slate-200 sm:p-7 dark:bg-zinc-900/60 dark:ring-white/10">
<div className="grid gap-4 sm:grid-cols-[1fr_auto_1fr] sm:items-end">
{/* From */}
<div>
<label
htmlFor="calcunit-amount"
className="mb-2 block text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-zinc-500"
>
From
</label>
<input
id="calcunit-amount"
type="text"
inputMode="decimal"
autoComplete="off"
spellCheck={false}
value={amount}
onChange={onAmountChange}
placeholder="0"
aria-describedby="calcunit-formula"
className="w-full rounded-xl border border-slate-200 bg-white px-3.5 py-3 text-lg font-semibold tabular-nums text-slate-900 outline-none transition-colors placeholder:text-slate-400 hover:border-slate-300 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-50 dark:placeholder:text-zinc-600 dark:hover:border-white/20 dark:focus-visible:ring-offset-zinc-900"
/>
<div className="relative mt-2">
<select
aria-label="Convert from unit"
value={pair.from}
onChange={(e) => setPair({ from: e.target.value })}
className={`${SELECT_CLASS} focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-zinc-900`}
>
{cat.units.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.sym})
</option>
))}
</select>
<Chevron />
</div>
</div>
{/* Swap */}
<div className="flex justify-center sm:pb-2">
<button
type="button"
onClick={swap}
aria-label={`Swap units — show ${toUnit.name} in ${fromUnit.name}`}
className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-slate-600 ring-1 ring-slate-200 outline-none transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-zinc-900 dark:text-zinc-400 dark:ring-white/10 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-offset-zinc-900"
>
<motion.span
animate={{ rotate: reduce ? 0 : spins * 180 }}
transition={spring}
className="flex"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7 4v13" />
<path d="M4 7l3-3 3 3" />
<path d="M17 20V7" />
<path d="M14 17l3 3 3-3" />
</svg>
</motion.span>
</button>
</div>
{/* To */}
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-zinc-500">
To
</p>
<div className="relative overflow-hidden rounded-xl border border-indigo-200 bg-indigo-50 px-3.5 py-3 dark:border-indigo-400/25 dark:bg-indigo-500/10">
<span
aria-hidden="true"
className="calcunit-sheen pointer-events-none absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/50 to-transparent dark:via-white/10"
/>
<output
htmlFor="calcunit-amount"
aria-live="polite"
className="relative block truncate font-semibold tabular-nums text-indigo-700 dark:text-indigo-200"
>
<span key={resultText} className={`${sizeClass} calcunit-pop inline-block`}>
{resultText}
</span>
</output>
</div>
<div className="relative mt-2">
<select
aria-label="Convert to unit"
value={pair.to}
onChange={(e) => setPair({ to: e.target.value })}
className={`${SELECT_CLASS} focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-zinc-900`}
>
{cat.units.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.sym})
</option>
))}
</select>
<Chevron />
</div>
</div>
</div>
{/* Formula + controls */}
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-slate-200 pt-5 dark:border-white/10">
<p
id="calcunit-formula"
className="font-mono text-xs text-slate-500 dark:text-zinc-500"
>
{formula(fromUnit, toUnit)}
</p>
<div className="flex flex-wrap items-center gap-3">
<fieldset className="flex items-center gap-1.5">
<legend className="sr-only">Decimal places</legend>
<span
aria-hidden="true"
className="mr-1 text-xs font-medium text-slate-500 dark:text-zinc-500"
>
Decimals
</span>
{PRECISIONS.map((p) => (
<label key={p.id} className="relative cursor-pointer">
<input
type="radio"
name="calcunit-precision"
value={p.id}
checked={precisionId === p.id}
onChange={() => {
setPrecisionId(p.id);
setCopied(false);
}}
className="peer sr-only"
/>
<span className="flex h-7 items-center justify-center rounded-lg px-2.5 text-xs font-semibold text-slate-500 ring-1 ring-slate-200 transition-colors hover:text-slate-900 peer-checked:bg-slate-900 peer-checked:text-white peer-checked:ring-slate-900 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-slate-50 dark:text-zinc-400 dark:ring-white/10 dark:hover:text-zinc-100 dark:peer-checked:bg-zinc-100 dark:peer-checked:text-zinc-950 dark:peer-checked:ring-zinc-100 dark:peer-focus-visible:ring-offset-zinc-900">
{p.label}
</span>
</label>
))}
</fieldset>
<button
type="button"
onClick={copyResult}
disabled={resultRaw === ""}
className="flex h-9 items-center gap-1.5 rounded-lg bg-slate-900 px-3 text-xs font-semibold text-white outline-none transition-colors hover:bg-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-white dark:focus-visible:ring-offset-zinc-900"
>
{copied ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="calcunit-tick"
>
<path d="M4.5 12.5l5 5 10-11" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15H4a1 1 0 01-1-1V4a1 1 0 011-1h10a1 1 0 011 1v1" />
</svg>
)}
{copied ? "Copied" : "Copy"}
</button>
<span aria-live="polite" className="sr-only">
{copied ? "Result copied to clipboard" : ""}
</span>
</div>
</div>
</div>
</div>
{/* All units at once */}
<div className="lg:col-span-2">
<div className="h-full rounded-3xl border border-slate-200 p-5 sm:p-6 dark:border-white/10">
<div className="mb-4 flex items-baseline justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-zinc-100">
{parsed === null ? "Enter a value" : `${group(String(parsed))} ${fromUnit.sym} equals`}
</h3>
<span className="text-xs text-slate-400 dark:text-zinc-600">
base: {cat.base}
</span>
</div>
<ul className="space-y-1">
{cat.units
.filter((u) => u.id !== fromUnit.id)
.map((u) => {
const active = u.id === toUnit.id;
const v = parsed === null ? null : convert(parsed, fromUnit, u);
return (
<li key={u.id}>
<button
type="button"
onClick={() => setPair({ to: u.id })}
aria-pressed={active}
aria-label={`Show the result in ${u.name}`}
className={`flex w-full items-baseline justify-between gap-3 rounded-xl px-3 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950 ${
active
? "bg-indigo-50 dark:bg-indigo-500/10"
: "hover:bg-slate-50 dark:hover:bg-zinc-900"
}`}
>
<span
className={`truncate text-sm ${
active
? "font-semibold text-indigo-700 dark:text-indigo-300"
: "text-slate-600 dark:text-zinc-400"
}`}
>
{u.name}
</span>
<span
className={`shrink-0 text-sm tabular-nums ${
active
? "font-semibold text-indigo-700 dark:text-indigo-300"
: "font-medium text-slate-900 dark:text-zinc-200"
}`}
>
{v === null ? "—" : fmt(v, digits)}{" "}
<span className="text-xs font-normal text-slate-400 dark:text-zinc-600">
{u.sym}
</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
</div>
</div>
</div>
<p className="mt-6 text-xs text-slate-500 dark:text-zinc-500">
Factors follow the international yard-and-pound agreement (1959) and SI definitions.
Mach assumes 340.29 m/s — dry air at sea level, 15 °C.
</p>
</div>
</section>
);
}
function Chevron() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-zinc-500"
>
<path d="M6 9.5l6 6 6-6" />
</svg>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Calculator Basic
Originalbasic calculator keypad UI

Calculator Pricing
Originalinteractive pricing calculator

Calculator Loan
Originalloan / mortgage calculator with sliders

Calculator Tip
Originaltip and split calculator

Calculator ROI
OriginalROI / savings calculator

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

Image Backdrop Hero
OriginalA cinematic full-bleed hero with a layered image-style backdrop, overlaid left-aligned copy and a trusted-by logos strip.

Gradient Mesh Hero with Staggered Text Reveal
OriginalA full-screen hero pairing a drifting animated gradient-mesh backdrop with a word-by-word staggered blur-in headline and a logo marquee under the fold.

