Web InnoventixFreeCode

Calculator Basic

Original · free

basic calculator keypad UI

byWeb InnoventixReact + Tailwind
calcbasiccalculators
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-basic.json
calc-basic.tsx
"use client";

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type Op = "+" | "−" | "×" | "÷";

type Action =
  | { t: "digit"; v: string }
  | { t: "dot" }
  | { t: "op"; v: Op }
  | { t: "eq" }
  | { t: "ac" }
  | { t: "sign" }
  | { t: "pct" }
  | { t: "back" };

type Kind = "num" | "fn" | "op" | "eq";

type KeyDef = {
  id: string;
  label: string;
  aria: string;
  kind: Kind;
  action: Action;
  wide?: boolean;
};

type TapeRow = { id: number; expr: string; raw: string };

const MAX_LEN = 12;

const KEYS: readonly KeyDef[] = [
  { id: "ac", label: "AC", aria: "All clear", kind: "fn", action: { t: "ac" } },
  { id: "sign", label: "±", aria: "Toggle sign", kind: "fn", action: { t: "sign" } },
  { id: "pct", label: "%", aria: "Percent", kind: "fn", action: { t: "pct" } },
  { id: "div", label: "÷", aria: "Divide", kind: "op", action: { t: "op", v: "÷" } },
  { id: "7", label: "7", aria: "Seven", kind: "num", action: { t: "digit", v: "7" } },
  { id: "8", label: "8", aria: "Eight", kind: "num", action: { t: "digit", v: "8" } },
  { id: "9", label: "9", aria: "Nine", kind: "num", action: { t: "digit", v: "9" } },
  { id: "mul", label: "×", aria: "Multiply", kind: "op", action: { t: "op", v: "×" } },
  { id: "4", label: "4", aria: "Four", kind: "num", action: { t: "digit", v: "4" } },
  { id: "5", label: "5", aria: "Five", kind: "num", action: { t: "digit", v: "5" } },
  { id: "6", label: "6", aria: "Six", kind: "num", action: { t: "digit", v: "6" } },
  { id: "sub", label: "−", aria: "Subtract", kind: "op", action: { t: "op", v: "−" } },
  { id: "1", label: "1", aria: "One", kind: "num", action: { t: "digit", v: "1" } },
  { id: "2", label: "2", aria: "Two", kind: "num", action: { t: "digit", v: "2" } },
  { id: "3", label: "3", aria: "Three", kind: "num", action: { t: "digit", v: "3" } },
  { id: "add", label: "+", aria: "Add", kind: "op", action: { t: "op", v: "+" } },
  { id: "0", label: "0", aria: "Zero", kind: "num", action: { t: "digit", v: "0" }, wide: true },
  { id: "dot", label: ".", aria: "Decimal point", kind: "num", action: { t: "dot" } },
  { id: "eq", label: "=", aria: "Equals", kind: "eq", action: { t: "eq" } },
];

const BACK_KEY: KeyDef = {
  id: "back",
  label: "⌫",
  aria: "Backspace",
  kind: "fn",
  action: { t: "back" },
};

const ACTION_BY_ID: ReadonlyMap<string, Action> = new Map(
  [...KEYS, BACK_KEY].map((k) => [k.id, k.action] as const),
);

function compute(a: number, b: number, op: Op): number {
  switch (op) {
    case "+":
      return a + b;
    case "−":
      return a - b;
    case "×":
      return a * b;
    case "÷":
      return b === 0 ? Number.NaN : a / b;
  }
}

function format(n: number): string {
  if (!Number.isFinite(n)) return "Error";
  const rounded = Number(n.toPrecision(12));
  const mag = Math.abs(rounded);
  if (rounded !== 0 && (mag >= 1e12 || mag < 1e-9)) {
    return rounded.toExponential(6);
  }
  return String(rounded);
}

function group(raw: string): string {
  if (raw === "Error") return raw;
  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 fracPart = dot === -1 ? null : body.slice(dot + 1);
  const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  const joined = fracPart === null ? grouped : `${grouped}.${fracPart}`;
  return neg ? `-${joined}` : joined;
}

function idForEventKey(k: string): string | null {
  if (/^[0-9]$/.test(k)) return k;
  switch (k) {
    case ".":
    case ",":
      return "dot";
    case "+":
      return "add";
    case "-":
      return "sub";
    case "*":
    case "x":
    case "X":
      return "mul";
    case "/":
      return "div";
    case "=":
    case "Enter":
      return "eq";
    case "%":
      return "pct";
    case "n":
    case "N":
      return "sign";
    case "Backspace":
    case "Delete":
      return "back";
    case "Escape":
    case "c":
    case "C":
      return "ac";
    default:
      return null;
  }
}

const KIND_CLASS: Record<Kind, string> = {
  num: "bg-white text-slate-900 ring-1 ring-slate-200 hover:bg-slate-50 dark:bg-zinc-800 dark:text-zinc-50 dark:ring-white/10 dark:hover:bg-zinc-700",
  fn: "bg-slate-100 text-slate-600 ring-1 ring-slate-200 hover:bg-slate-200 dark:bg-zinc-800/60 dark:text-zinc-300 dark:ring-white/10 dark:hover:bg-zinc-700/70",
  op: "bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:ring-indigo-400/25 dark:hover:bg-indigo-500/25",
  eq: "bg-indigo-600 text-white ring-1 ring-indigo-600 hover:bg-indigo-500 dark:bg-indigo-500 dark:ring-indigo-400/40 dark:hover:bg-indigo-400",
};

export default function CalcBasic() {
  const reduce = useReducedMotion() ?? false;

  const [display, setDisplay] = useState<string>("0");
  const [acc, setAcc] = useState<number | null>(null);
  const [op, setOp] = useState<Op | null>(null);
  const [overwrite, setOverwrite] = useState<boolean>(true);
  const [tape, setTape] = useState<TapeRow[]>([]);
  const [pop, setPop] = useState<number>(0);
  const [flash, setFlash] = useState<string | null>(null);
  const [copied, setCopied] = useState<boolean>(false);

  const tapeSeq = useRef<number>(0);
  const flashTimer = useRef<number | null>(null);
  const copyTimer = useRef<number | null>(null);

  useEffect(() => {
    return () => {
      if (flashTimer.current !== null) window.clearTimeout(flashTimer.current);
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    };
  }, []);

  const pulse = useCallback((id: string) => {
    setFlash(id);
    if (flashTimer.current !== null) window.clearTimeout(flashTimer.current);
    flashTimer.current = window.setTimeout(() => setFlash(null), 220);
  }, []);

  const pressDigit = useCallback(
    (d: string) => {
      setDisplay((prev) => {
        if (overwrite || prev === "Error" || prev === "0") return d;
        if (prev.replace(/[-.]/g, "").length >= MAX_LEN) return prev;
        return prev + d;
      });
      setOverwrite(false);
      setCopied(false);
    },
    [overwrite],
  );

  const pressDot = useCallback(() => {
    setDisplay((prev) => {
      if (overwrite || prev === "Error") return "0.";
      return prev.includes(".") ? prev : `${prev}.`;
    });
    setOverwrite(false);
    setCopied(false);
  }, [overwrite]);

  const pressSign = useCallback(() => {
    setDisplay((prev) => {
      if (prev === "Error" || prev === "0") return prev;
      return prev.startsWith("-") ? prev.slice(1) : `-${prev}`;
    });
    setCopied(false);
  }, []);

  const pressPct = useCallback(() => {
    setDisplay((prev) => {
      if (prev === "Error") return prev;
      const v = Number.parseFloat(prev);
      return Number.isNaN(v) ? prev : format(v / 100);
    });
    setOverwrite(true);
    setCopied(false);
  }, []);

  const pressBack = useCallback(() => {
    setDisplay((prev) => {
      if (prev === "Error") return "0";
      if (prev.length <= 1) return "0";
      if (prev.length === 2 && prev.startsWith("-")) return "0";
      return prev.slice(0, -1);
    });
    setOverwrite(false);
    setCopied(false);
  }, []);

  const pressAc = useCallback(() => {
    setDisplay("0");
    setAcc(null);
    setOp(null);
    setOverwrite(true);
    setCopied(false);
  }, []);

  const pressOp = useCallback(
    (next: Op) => {
      if (display === "Error") return;
      const value = Number.parseFloat(display);
      if (Number.isNaN(value)) return;

      if (acc !== null && op !== null && !overwrite) {
        const r = compute(acc, value, op);
        setDisplay(format(r));
        if (!Number.isFinite(r)) {
          setAcc(null);
          setOp(null);
          setOverwrite(true);
          return;
        }
        setAcc(r);
      } else {
        setAcc(value);
      }
      setOp(next);
      setOverwrite(true);
      setCopied(false);
    },
    [display, acc, op, overwrite],
  );

  const pressEq = useCallback(() => {
    if (acc === null || op === null || display === "Error") return;
    const value = Number.parseFloat(display);
    if (Number.isNaN(value)) return;

    const r = compute(acc, value, op);
    const raw = format(r);
    const expr = `${group(format(acc))} ${op} ${group(format(value))}`;

    tapeSeq.current += 1;
    const row: TapeRow = { id: tapeSeq.current, expr, raw };
    setTape((prev) => [row, ...prev].slice(0, 8));

    setDisplay(raw);
    setAcc(null);
    setOp(null);
    setOverwrite(true);
    setPop((p) => p + 1);
    setCopied(false);
  }, [acc, op, display]);

  const run = useCallback(
    (a: Action) => {
      switch (a.t) {
        case "digit":
          pressDigit(a.v);
          break;
        case "dot":
          pressDot();
          break;
        case "op":
          pressOp(a.v);
          break;
        case "eq":
          pressEq();
          break;
        case "ac":
          pressAc();
          break;
        case "sign":
          pressSign();
          break;
        case "pct":
          pressPct();
          break;
        case "back":
          pressBack();
          break;
      }
    },
    [pressDigit, pressDot, pressOp, pressEq, pressAc, pressSign, pressPct, pressBack],
  );

  const onKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLDivElement>) => {
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      const target = e.target;
      const onButton = target instanceof HTMLButtonElement;
      if (onButton && (e.key === "Enter" || e.key === " ")) return;

      const id = idForEventKey(e.key);
      if (id === null) return;
      const action = ACTION_BY_ID.get(id);
      if (action === undefined) return;

      e.preventDefault();
      run(action);
      pulse(id);
    },
    [run, pulse],
  );

  const recall = useCallback((raw: string) => {
    setDisplay(raw);
    setAcc(null);
    setOp(null);
    setOverwrite(true);
    setCopied(false);
  }, []);

  const copyResult = useCallback(() => {
    const write = async (): Promise<void> => {
      try {
        await navigator.clipboard.writeText(display);
        setCopied(true);
        if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
        copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
      } catch {
        setCopied(false);
      }
    };
    void write();
  }, [display]);

  const isError = display === "Error";
  const shown = group(display);
  const trail = acc !== null && op !== null ? `${group(format(acc))} ${op}` : "";
  const sizeClass =
    shown.length > 12
      ? "text-3xl sm:text-4xl"
      : shown.length > 9
        ? "text-4xl sm:text-5xl"
        : "text-5xl sm:text-6xl";

  const keyBase =
    "relative flex h-14 items-center justify-center rounded-2xl text-lg font-medium tabular-nums select-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:h-16 sm:text-xl dark:focus-visible:ring-offset-zinc-950";

  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 calcbasic-flash {
          0%   { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); }
          100% { box-shadow: 0 0 0 12px rgba(99, 102, 241, 0); }
        }
        @keyframes calcbasic-caret {
          0%, 45%   { opacity: 1; }
          50%, 95%  { opacity: 0; }
          100%      { opacity: 1; }
        }
        .calcbasic-flash-on { animation: calcbasic-flash 220ms ease-out; }
        .calcbasic-caret { animation: calcbasic-caret 1.05s steps(1, end) infinite; }
        @media (prefers-reduced-motion: reduce) {
          .calcbasic-flash-on,
          .calcbasic-caret { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-4xl">
        <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">
            Four functions, no surprises
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
            Add, subtract, multiply, divide — with a running tape you can click to pull a number
            back into the display. Focus the pad and type: digits, <span className="font-mono">+ - * /</span>,{" "}
            <span className="font-mono">Enter</span> to solve, <span className="font-mono">Esc</span> to clear.
          </p>
        </header>

        <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_17rem] lg:items-start lg:gap-8">
          <div
            role="group"
            aria-label="Basic calculator"
            tabIndex={0}
            onKeyDown={onKeyDown}
            className="rounded-3xl bg-slate-50 p-4 outline-none ring-1 ring-slate-200 focus-visible:ring-2 focus-visible:ring-indigo-500 sm:p-5 dark:bg-zinc-900 dark:ring-white/10 dark:focus-visible:ring-indigo-400"
          >
            <div className="rounded-2xl bg-white px-4 py-4 ring-1 ring-slate-200 sm:px-5 sm:py-5 dark:bg-zinc-950 dark:ring-white/10">
              <div className="flex items-start justify-between gap-3">
                <p
                  aria-hidden="true"
                  className="min-h-5 truncate font-mono text-sm text-slate-400 tabular-nums dark:text-zinc-500"
                >
                  {trail === "" ? " " : trail}
                </p>
                <div className="flex shrink-0 items-center gap-1">
                  <button
                    type="button"
                    onClick={copyResult}
                    aria-label={copied ? "Result copied to clipboard" : "Copy result to clipboard"}
                    className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 outline-none transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
                  >
                    {copied ? (
                      <svg
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={2}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden="true"
                        className="h-4 w-4 text-emerald-600 dark:text-emerald-400"
                      >
                        <path d="m4 12.5 5 5L20 6.5" />
                      </svg>
                    ) : (
                      <svg
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth={1.7}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden="true"
                        className="h-4 w-4"
                      >
                        <rect x="9" y="9" width="11" height="11" rx="2" />
                        <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
                      </svg>
                    )}
                  </button>
                  <button
                    type="button"
                    onClick={() => {
                      pressBack();
                      pulse("back");
                    }}
                    aria-label={BACK_KEY.aria}
                    className={`flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 outline-none transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200 ${
                      flash === "back" ? "calcbasic-flash-on" : ""
                    }`}
                  >
                    <svg
                      viewBox="0 0 24 24"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={1.7}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                      className="h-[18px] w-[18px]"
                    >
                      <path d="M9 5h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-6-7 6-7Z" />
                      <path d="m15 9.5-4 5" />
                      <path d="m11 9.5 4 5" />
                    </svg>
                  </button>
                </div>
              </div>

              <div className="mt-1 flex items-end justify-end overflow-x-auto">
                <motion.div
                  key={pop}
                  initial={reduce ? false : { scale: 0.965, opacity: 0.55 }}
                  animate={{ scale: 1, opacity: 1 }}
                  transition={{ type: "spring", stiffness: 460, damping: 28 }}
                  aria-hidden="true"
                  className={`flex items-baseline whitespace-nowrap font-semibold tabular-nums tracking-tight ${sizeClass} ${
                    isError ? "text-rose-600 dark:text-rose-400" : "text-slate-900 dark:text-zinc-50"
                  }`}
                >
                  <span>{shown}</span>
                  {!overwrite && !isError ? (
                    <span
                      aria-hidden="true"
                      className="calcbasic-caret ml-0.5 inline-block h-[0.9em] w-[3px] shrink-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
                    />
                  ) : null}
                </motion.div>
              </div>

              <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
                {isError ? "Error. Cannot divide by zero. Press all clear to continue." : shown}
              </span>
            </div>

            <div className="mt-4 grid grid-cols-4 gap-2 sm:gap-2.5">
              {KEYS.map((k) => (
                <motion.button
                  key={k.id}
                  type="button"
                  aria-label={k.aria}
                  onClick={() => {
                    run(k.action);
                    pulse(k.id);
                  }}
                  whileTap={reduce ? undefined : { scale: 0.95 }}
                  transition={{ type: "spring", stiffness: 620, damping: 30 }}
                  className={`${keyBase} ${KIND_CLASS[k.kind]} ${k.wide === true ? "col-span-2" : ""} ${
                    flash === k.id ? "calcbasic-flash-on" : ""
                  }`}
                >
                  {k.label}
                </motion.button>
              ))}
            </div>

            <ul className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-500 dark:text-zinc-500">
              {[
                { k: "0–9", d: "digits" },
                { k: "+ − * /", d: "operators" },
                { k: "Enter", d: "equals" },
                { k: "Esc", d: "clear" },
                { k: "n", d: "sign" },
              ].map((hint) => (
                <li key={hint.k} className="flex items-center gap-1.5">
                  <kbd className="rounded-md border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] font-normal text-slate-600 dark:border-white/15 dark:bg-zinc-800 dark:text-zinc-300">
                    {hint.k}
                  </kbd>
                  <span>{hint.d}</span>
                </li>
              ))}
            </ul>
          </div>

          <aside className="rounded-3xl bg-slate-50 p-4 ring-1 ring-slate-200 sm:p-5 dark:bg-zinc-900 dark:ring-white/10">
            <div className="flex items-center justify-between gap-2">
              <h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-zinc-100">
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={1.7}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden="true"
                  className="h-4 w-4 text-indigo-600 dark:text-indigo-400"
                >
                  <path d="M3.2 11a9 9 0 1 1 1.6 6" />
                  <path d="M3 3.5V8h4.5" />
                  <path d="M12 7.5V12l3 2" />
                </svg>
                Tape
                <span className="rounded-full bg-slate-200 px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-slate-600 dark:bg-zinc-800 dark:text-zinc-400">
                  {tape.length}
                </span>
              </h3>
              <button
                type="button"
                onClick={() => setTape([])}
                disabled={tape.length === 0}
                className="rounded-lg px-2 py-1 text-xs font-medium text-slate-500 outline-none transition-colors hover:bg-slate-200 hover:text-slate-800 focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
              >
                Clear
              </button>
            </div>

            {tape.length === 0 ? (
              <p className="mt-4 rounded-2xl border border-dashed border-slate-300 px-3 py-6 text-center text-xs leading-relaxed text-slate-500 dark:border-white/10 dark:text-zinc-500">
                Nothing recorded yet. Press <span className="font-mono">=</span> and your result lands here.
              </p>
            ) : (
              <ul className="mt-3 space-y-1.5">
                {tape.map((row) => (
                  <motion.li
                    key={row.id}
                    initial={reduce ? false : { opacity: 0, x: 10 }}
                    animate={{ opacity: 1, x: 0 }}
                    transition={{ duration: 0.2, ease: "easeOut" }}
                  >
                    <button
                      type="button"
                      onClick={() => recall(row.raw)}
                      aria-label={`Recall ${group(row.raw)}, the result of ${row.expr}`}
                      className="w-full rounded-xl bg-white px-3 py-2 text-right outline-none ring-1 ring-slate-200 transition-colors hover:bg-indigo-50 hover:ring-indigo-200 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-zinc-950 dark:ring-white/10 dark:hover:bg-indigo-500/10 dark:hover:ring-indigo-400/30"
                    >
                      <span className="block truncate font-mono text-[11px] text-slate-400 tabular-nums dark:text-zinc-500">
                        {row.expr}
                      </span>
                      <span
                        className={`block truncate text-sm font-semibold tabular-nums ${
                          row.raw === "Error"
                            ? "text-rose-600 dark:text-rose-400"
                            : "text-slate-900 dark:text-zinc-100"
                        }`}
                      >
                        {group(row.raw)}
                      </span>
                    </button>
                  </motion.li>
                ))}
              </ul>
            )}

            <p className="mt-4 border-t border-slate-200 pt-3 text-xs leading-relaxed text-slate-500 dark:border-white/10 dark:text-zinc-500">
              The tape keeps your last eight results. Click any row to send that number back to the
              display and keep working from it.
            </p>
          </aside>
        </div>
      </div>
    </section>
  );
}

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 →