Web InnoventixFreeCode

Calculator Unit Converter

Original · free

unit converter

byWeb InnoventixReact + Tailwind
calcunitconvertercalculators
Open

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
calc-unit-converter.tsx
"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

motion

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 quote

Similar components

Browse all →