Calc
import { useState } from 'react';
const css = `
@keyframes field-calc-roll { from { transform: translateY(70%); opacity: 0; } }
@keyframes field-calc-commit { 0% { background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, light-dark(#f3f4f7, #1a1c1f)); } 100% { background: light-dark(#f3f4f7, #1a1c1f); } }
@keyframes field-calc-was { from { opacity: 0; transform: translateY(-6px); } }
.field-calc { width: 250px; }
.field-calc__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-calc__box {
display: flex;
align-items: center;
gap: 6px;
height: 44px;
padding: 0 12px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
transition: box-shadow 0.2s;
}
.field-calc__box[data-flash] { animation: field-calc-commit 0.6s ease-out; }
.field-calc__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-calc__cur { color: light-dark(#5f626a, #a7abb2); font-size: 15px; }
.field-calc__input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
.field-calc__result { display: flex; overflow: hidden; color: light-dark(#42454c, #e5e6e9); font: 600 14px ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
.field-calc__result span { display: inline-block; animation: field-calc-roll 0.28s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
.field-calc__eq { color: light-dark(#5f626a, #a7abb2); margin-right: 4px; }
.field-calc__foot { display: flex; justify-content: space-between; min-height: 16px; margin-top: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.field-calc__was { animation: field-calc-was 0.3s ease-out; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (prefers-reduced-motion: reduce) {
.field-calc__result span, .field-calc__box[data-flash], .field-calc__was { animation: none; }
}
.field-calc__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-calc__top > :first-child { margin: 0; }
.field-calc__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-calc__info svg { width: 14px; height: 14px; }
.field-calc__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-calc__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-calc__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-calc__info:hover::after, .field-calc__info:focus-visible::after { opacity: 1; transform: none; }
`;
/** A tiny arithmetic parser: + - * / x ( ) and a trailing % on any number. Throws on anything else. */
function evaluate(src: string): number {
const s = src.replace(/\s+/g, '').replace(/,/g, '').replace(/[x×]/g, '*').replace(/÷/g, '/');
let i = 0;
const num = () => {
const m = /^\d*\.?\d+/.exec(s.slice(i));
if (!m) throw new Error('number');
i += m[0].length;
let v = parseFloat(m[0]);
if (s[i] === '%') {
i++;
v /= 100;
}
return v;
};
const factor = (): number => {
if (s[i] === '-') {
i++;
return -factor();
}
if (s[i] === '(') {
i++;
const v = expr();
if (s[i++] !== ')') throw new Error('paren');
return v;
}
return num();
};
const term = () => {
let v = factor();
while (s[i] === '*' || s[i] === '/') {
const op = s[i++];
const r = factor();
v = op === '*' ? v * r : v / r;
}
return v;
};
const expr = (): number => {
let v = term();
while (s[i] === '+' || s[i] === '-') {
const op = s[i++];
const r = term();
v = op === '+' ? v + r : v - r;
}
return v;
};
const v = expr();
if (i !== s.length || !Number.isFinite(v)) throw new Error('trailing');
return v;
}
const fmt = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 2 });
export function CalcInput() {
const [value, setValue] = useState('1200 + 40.5');
const [was, setWas] = useState('');
const [flash, setFlash] = useState(0);
let result: number | null = null;
if (/[-+*/x×÷%(]/.test(value.replace(/^\s*-/, ''))) {
try {
result = evaluate(value);
} catch {
result = null;
}
}
const commit = () => {
if (result === null) return;
setWas(value.trim());
setValue(String(Math.round(result * 100) / 100));
setFlash((f) => f + 1);
};
const shown = result === null ? '' : fmt(result);
return (
<>
<style>
{css}
</style>
<div className="field-calc">
<div className="field-calc__top">
<label className="field-calc__label" htmlFor="field-calc-input">
Budget
</label>
<button type="button" className="field-calc__info" aria-label="Type a sum; Enter uses the total" data-tip="Type a sum; Enter uses the total">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 7.5v.01" />
</svg>
</button>
</div>
<div key={flash} className="field-calc__box" data-flash={flash || undefined}>
<span className="field-calc__cur" aria-hidden="true">
$
</span>
<input
id="field-calc-input"
className="field-calc__input"
value={value}
inputMode="decimal"
autoComplete="off"
spellCheck={false}
aria-describedby="field-calc-out"
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && commit()}
autoFocus={flash > 0}
/>
<span id="field-calc-out" className="field-calc__result" aria-live="polite">
{shown && <span className="field-calc__eq">=</span>}
{[...shown].map((c, i) => (
<span key={`${i}${c}`} style={{ animationDelay: `${i * 18}ms` }}>
{c}
</span>
))}
</span>
</div>
<div className="field-calc__foot">
<span />
{was && (
<span key={was} className="field-calc__was">
{was}
</span>
)}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldCalc(el) on the mounted element instead.
<div class="field-calc">
<div class="field-calc__top">
<label class="field-calc__label" for="field-calc-input">Budget</label>
<button type="button" class="field-calc__info" aria-label="Type a sum; Enter uses the total" data-tip="Type a sum; Enter uses the total">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M12 11v5M12 7.5v.01"></path>
</svg>
</button>
</div>
<div class="field-calc__box">
<span class="field-calc__cur" aria-hidden="true">$</span>
<input id="field-calc-input" class="field-calc__input" inputMode="decimal" autoComplete="off" spellCheck="false" aria-describedby="field-calc-out" value="1200 + 40.5"/>
<span id="field-calc-out" class="field-calc__result" aria-live="polite">
<span class="field-calc__eq">=</span>
<span style="animation-delay:0ms">1</span>
<span style="animation-delay:18ms">,</span>
<span style="animation-delay:36ms">2</span>
<span style="animation-delay:54ms">4</span>
<span style="animation-delay:72ms">0</span>
<span style="animation-delay:90ms">.</span>
<span style="animation-delay:108ms">5</span>
</span>
</div>
<div class="field-calc__foot">
<span></span>
</div>
</div>@keyframes field-calc-roll { from { transform: translateY(70%); opacity: 0; } }
@keyframes field-calc-commit { 0% { background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, light-dark(#f3f4f7, #1a1c1f)); } 100% { background: light-dark(#f3f4f7, #1a1c1f); } }
@keyframes field-calc-was { from { opacity: 0; transform: translateY(-6px); } }
.field-calc { width: 250px; }
.field-calc__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-calc__box {
display: flex;
align-items: center;
gap: 6px;
height: 44px;
padding: 0 12px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
transition: box-shadow 0.2s;
}
.field-calc__box[data-flash] { animation: field-calc-commit 0.6s ease-out; }
.field-calc__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-calc__cur { color: light-dark(#5f626a, #a7abb2); font-size: 15px; }
.field-calc__input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); font: 15px ui-monospace, SFMono-Regular, Menlo, monospace; }
.field-calc__result { display: flex; overflow: hidden; color: light-dark(#42454c, #e5e6e9); font: 600 14px ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; }
.field-calc__result span { display: inline-block; animation: field-calc-roll 0.28s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
.field-calc__eq { color: light-dark(#5f626a, #a7abb2); margin-right: 4px; }
.field-calc__foot { display: flex; justify-content: space-between; min-height: 16px; margin-top: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.field-calc__was { animation: field-calc-was 0.3s ease-out; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (prefers-reduced-motion: reduce) {
.field-calc__result span, .field-calc__box[data-flash], .field-calc__was { animation: none; }
}
.field-calc__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-calc__top > :first-child { margin: 0; }
.field-calc__info { position: relative; display: grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: none; color: light-dark(#5f626a, #a7abb2); cursor: help; }
.field-calc__info svg { width: 14px; height: 14px; }
.field-calc__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-calc__info:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 1px; }
/* How-to lives behind the icon, so the field itself stays minimal. */
.field-calc__info::after {
content: attr(data-tip);
position: absolute;
left: -6px;
bottom: calc(100% + 6px);
z-index: 5;
padding: 4px 8px;
border-radius: 6px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-family: inherit; font-size: 11px; font-weight: 500; line-height: 1.3;
width: max-content;
max-width: 150px;
opacity: 0;
transform: translateY(3px);
pointer-events: none;
transition: opacity 0.15s, transform 0.15s;
}
.field-calc__info:hover::after, .field-calc__info:focus-visible::after { opacity: 1; transform: none; }function initFieldCalc(root) {
var box = root.querySelector('.field-calc__box');
var input = root.querySelector('.field-calc__input');
var out = root.querySelector('.field-calc__result');
var foot = root.querySelector('.field-calc__foot');
if (!box || !input || !out || !foot) return;
// A tiny arithmetic parser: + - * / x ( ) and a trailing % on any number. Throws on anything else.
function evaluate(src) {
var s = src.replace(/\s+/g, '').replace(/,/g, '').replace(/[x×]/g, '*').replace(/÷/g, '/');
var i = 0;
function num() {
var m = /^\d*\.?\d+/.exec(s.slice(i));
if (!m) throw new Error('number');
i += m[0].length;
var v = parseFloat(m[0]);
if (s[i] === '%') {
i++;
v /= 100;
}
return v;
}
function factor() {
if (s[i] === '-') {
i++;
return -factor();
}
if (s[i] === '(') {
i++;
var v = expr();
if (s[i++] !== ')') throw new Error('paren');
return v;
}
return num();
}
function term() {
var v = factor();
while (s[i] === '*' || s[i] === '/') {
var op = s[i++];
var r = factor();
v = op === '*' ? v * r : v / r;
}
return v;
}
function expr() {
var v = term();
while (s[i] === '+' || s[i] === '-') {
var op = s[i++];
var r = term();
v = op === '+' ? v + r : v - r;
}
return v;
}
var v = expr();
if (i !== s.length || !isFinite(v)) throw new Error('trailing');
return v;
}
var result = null;
var shown = '';
function update() {
var value = input.value;
result = null;
if (/[-+*/x×÷%(]/.test(value.replace(/^\s*-/, ''))) {
try { result = evaluate(value); } catch (e) { result = null; }
}
var next = result === null ? '' : result.toLocaleString('en-US', { maximumFractionDigits: 2 });
// A character only replays its roll when it changes.
var kids = out.querySelectorAll('span:not(.field-calc__eq)');
if (!next) out.textContent = '';
else {
if (!out.querySelector('.field-calc__eq')) {
out.textContent = '';
var eq = document.createElement('span');
eq.className = 'field-calc__eq';
eq.textContent = '=';
out.appendChild(eq);
kids = [];
}
next.split('').forEach(function (c, i) {
if (kids[i] && shown[i] === c) return;
var sp = document.createElement('span');
sp.style.animationDelay = i * 18 + 'ms';
sp.textContent = c;
if (kids[i]) kids[i].replaceWith(sp);
else out.appendChild(sp);
});
Array.prototype.slice.call(out.querySelectorAll('span:not(.field-calc__eq)'), next.length).forEach(function (sp) { sp.remove(); });
}
shown = next;
}
function commit() {
if (result === null) return;
var was = foot.querySelector('.field-calc__was') || document.createElement('span');
was.className = 'field-calc__was';
was.textContent = input.value.trim();
foot.appendChild(was);
input.value = String(Math.round(result * 100) / 100);
box.removeAttribute('data-flash');
void box.offsetWidth; // restart the flash
box.setAttribute('data-flash', '');
update();
}
function key(e) {
if (e.key === 'Enter') commit();
}
input.addEventListener('input', update);
input.addEventListener('keydown', key);
update();
return function () {
input.removeEventListener('input', update);
input.removeEventListener('keydown', key);
};
}
document.querySelectorAll('.field-calc').forEach(initFieldCalc);Goal: Create a "Calc" input.
Visual behavior: An amount field that does arithmetic: type a sum and the total rolls in beside it digit by digit; Enter swaps the total in with a flash and keeps the sum it came from as a note below.
Technique:
- A tiny recursive-descent parser handles + - * / x, brackets, unary minus and a trailing % on numbers; anything else simply shows no result.
- The result is formatted with thousands separators and each character is its own span that rolls up with a delay from its index; a character only replays when it changes.
- Enter writes the rounded total into the input, flashes the field and shows the original sum beneath. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The result is an aria-live region tied to the input with aria-describedby. Under prefers-reduced-motion the digits and flash do not animate.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Calc" input.
Visual behavior: An amount field that does arithmetic: type a sum and the total rolls in beside it digit by digit; Enter swaps the total in with a flash and keeps the sum it came from as a note below.
Technique:
- A tiny recursive-descent parser handles + - * / x, brackets, unary minus and a trailing % on numbers; anything else simply shows no result.
- The result is formatted with thousands separators and each character is its own span that rolls up with a delay from its index; a character only replays when it changes.
- Enter writes the rounded total into the input, flashes the field and shows the original sum beneath. The how-to sits behind a small info button beside the label (its aria-label carries the same text), so the field itself stays minimal.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The result is an aria-live region tied to the input with aria-describedby. Under prefers-reduced-motion the digits and flash do not animate.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
An amount field that does arithmetic: type a sum and the total rolls in beside it digit by digit; Enter swaps the total in with a flash and keeps the sum it came from as a note below.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, delay stagger
- Techniques
- keyframes, state machine, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The result is an aria-live region tied to the input with aria-describedby. Under prefers-reduced-motion the digits and flash do not animate.