Calculator Basic
Original · freebasic calculator keypad UI
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"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
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 Pricing
Originalinteractive pricing calculator

Calculator Loan
Originalloan / mortgage calculator with sliders

Calculator Tip
Originaltip and split calculator

Calculator ROI
OriginalROI / savings calculator

Calculator Unit Converter
Originalunit converter

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.

