Tally
import { useState } from 'react';
const css = `
@keyframes field-tally-draw { to { stroke-dashoffset: 0; } }
@keyframes field-tally-roll { from { transform: translateY(60%); opacity: 0; } }
.field-tally { width: 240px; }
.field-tally__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.field-tally__label { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-tally__count { overflow: hidden; color: light-dark(#373a40, #f1f2f3); font-family: inherit; font-size: 22px; font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums; }
.field-tally__count span { display: inline-block; animation: field-tally-roll 0.25s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-tally__board { user-select: none; -webkit-user-select: none;
display: block;
width: 100%;
height: 78px;
padding: 0;
border: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
cursor: pointer;
transition: transform 0.08s;
}
.field-tally__board:active { transform: scale(0.98); }
.field-tally__board:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.field-tally__board svg { display: block; width: 240px; height: 78px; }
.field-tally__mark { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.6; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; animation: field-tally-draw 0.18s ease-out forwards; }
.field-tally__empty { fill: light-dark(#5f626a, #a7abb2); font-family: inherit; font-size: 12px; }
.field-tally__foot { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.field-tally__undo { padding: 3px 8px; border: 0; border-radius: 6px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-family: inherit; font-size: 11px; font-weight: 600; cursor: pointer; }
.field-tally__undo:disabled { opacity: 0.4; cursor: default; }
.field-tally__undo:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .field-tally__mark { animation: none; stroke-dashoffset: 0; } .field-tally__count span { animation: none; } }
.field-tally__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-tally__top > :first-child { margin: 0; }
.field-tally__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-tally__info svg { width: 14px; height: 14px; }
.field-tally__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-tally__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-tally__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-tally__info:hover::after, .field-tally__info:focus-visible::after { opacity: 1; transform: none; }
`;
const MAX_SHOWN = 30;
const jitter = (n: number, k: number) => {
const x = Math.sin(n * 91.7 + k * 13.3) * 43758.5;
return Math.round((x - Math.floor(x) - 0.5) * 240) / 100;
};
/** Stroke n of the tally: four uprights, then a slash across them, in groups of five. */
function mark(n: number) {
const g = Math.floor(n / 5);
const k = n % 5;
const x0 = 18 + g * 36;
if (k < 4) {
const x = x0 + k * 7;
return `M${x + jitter(n, 1)} ${22 + jitter(n, 2)} L${x + jitter(n, 3)} ${56 + jitter(n, 4)}`;
}
return `M${x0 - 5} ${50 + jitter(n, 5)} L${x0 + 27} ${28 + jitter(n, 6)}`;
}
export function TallyInput() {
const [n, setN] = useState(7);
return (
<>
<style>
{css}
</style>
<div className="field-tally">
<div className="field-tally__head">
<div className="field-tally__top">
<span id="field-tally-label" className="field-tally__label">
Laps
</span>
<button type="button" className="field-tally__info" aria-label="Tap the board to count" data-tip="Tap the board to count">
<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>
<span className="field-tally__count" aria-live="polite">
<span key={n}>{n}</span>
</span>
</div>
<button type="button" className="field-tally__board" aria-labelledby="field-tally-label" onClick={() => setN((c) => c + 1)}>
<svg viewBox="0 0 240 78" aria-hidden="true">
{n === 0 && (
<text className="field-tally__empty" x="120" y="44" textAnchor="middle">
Tap to count
</text>
)}
{Array.from({ length: Math.min(n, MAX_SHOWN) }, (_, i) => (
<path key={i} className="field-tally__mark" d={mark(i)} pathLength={1} />
))}
</svg>
</button>
<div className="field-tally__foot">
<span id="field-tally-hint">{n > MAX_SHOWN ? 'Board full, still counting' : ''}</span>
<button type="button" className="field-tally__undo" disabled={n === 0} onClick={() => setN((c) => Math.max(0, c - 1))}>
Undo
</button>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldTally(el) on the mounted element instead.
<div class="field-tally">
<div class="field-tally__head">
<div class="field-tally__top">
<span id="field-tally-label" class="field-tally__label">Laps</span>
<button type="button" class="field-tally__info" aria-label="Tap the board to count" data-tip="Tap the board to count">
<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>
<span class="field-tally__count" aria-live="polite">
<span>7</span>
</span>
</div>
<button type="button" class="field-tally__board" aria-labelledby="field-tally-label">
<svg viewBox="0 0 240 78" aria-hidden="true">
<path class="field-tally__mark" d="M17.68 21.19 L18.49 56.42" pathLength="1"></path>
<path class="field-tally__mark" d="M25.8 21.07 L24.18 55.67" pathLength="1"></path>
<path class="field-tally__mark" d="M32.35 22.39 L32.1 55.42" pathLength="1"></path>
<path class="field-tally__mark" d="M40.12 22.74 L39.99 55.33" pathLength="1"></path>
<path class="field-tally__mark" d="M13 50.19 L45 27.07" pathLength="1"></path>
<path class="field-tally__mark" d="M54.6 22.25 L53.07 56.65" pathLength="1"></path>
<path class="field-tally__mark" d="M60.87 23.17 L60.62 54.82" pathLength="1"></path>
</svg>
</button>
<div class="field-tally__foot">
<span id="field-tally-hint"></span>
<button type="button" class="field-tally__undo">Undo</button>
</div>
</div>@keyframes field-tally-draw { to { stroke-dashoffset: 0; } }
@keyframes field-tally-roll { from { transform: translateY(60%); opacity: 0; } }
.field-tally { width: 240px; }
.field-tally__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.field-tally__label { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-tally__count { overflow: hidden; color: light-dark(#373a40, #f1f2f3); font-family: inherit; font-size: 22px; font-weight: 600; line-height: 1; font-variant-numeric: tabular-nums; }
.field-tally__count span { display: inline-block; animation: field-tally-roll 0.25s cubic-bezier(0.2, 0.8, 0.3, 1); }
.field-tally__board { user-select: none; -webkit-user-select: none;
display: block;
width: 100%;
height: 78px;
padding: 0;
border: 0;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
cursor: pointer;
transition: transform 0.08s;
}
.field-tally__board:active { transform: scale(0.98); }
.field-tally__board:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.field-tally__board svg { display: block; width: 240px; height: 78px; }
.field-tally__mark { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.6; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; animation: field-tally-draw 0.18s ease-out forwards; }
.field-tally__empty { fill: light-dark(#5f626a, #a7abb2); font-family: inherit; font-size: 12px; }
.field-tally__foot { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; }
.field-tally__undo { padding: 3px 8px; border: 0; border-radius: 6px; background: light-dark(#ebedf0, #232529); color: light-dark(#42454c, #e5e6e9); font-family: inherit; font-size: 11px; font-weight: 600; cursor: pointer; }
.field-tally__undo:disabled { opacity: 0.4; cursor: default; }
.field-tally__undo:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .field-tally__mark { animation: none; stroke-dashoffset: 0; } .field-tally__count span { animation: none; } }
.field-tally__top { display: flex; align-items: center; gap: 5px; margin-bottom: 6px; }
.field-tally__top > :first-child { margin: 0; }
.field-tally__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-tally__info svg { width: 14px; height: 14px; }
.field-tally__info:hover { color: light-dark(#42454c, #e5e6e9); }
.field-tally__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-tally__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-tally__info:hover::after, .field-tally__info:focus-visible::after { opacity: 1; transform: none; }function initFieldTally(root) {
var board = root.querySelector('.field-tally__board');
var svg = root.querySelector('.field-tally__board svg');
var count = root.querySelector('.field-tally__count');
var hint = root.querySelector('#field-tally-hint') || root.querySelector('.field-tally__foot span');
var undo = root.querySelector('.field-tally__undo');
if (!board || !svg || !count || !hint || !undo) return;
var NS = 'http://www.w3.org/2000/svg';
var MAX_SHOWN = 30;
function jitter(n, k) {
var x = Math.sin(n * 91.7 + k * 13.3) * 43758.5;
return Math.round((x - Math.floor(x) - 0.5) * 240) / 100;
}
// Stroke n of the tally: four uprights, then a slash across them, in groups of five.
function mark(n) {
var g = Math.floor(n / 5);
var k = n % 5;
var x0 = 18 + g * 36;
if (k < 4) {
var x = x0 + k * 7;
return 'M' + (x + jitter(n, 1)) + ' ' + (22 + jitter(n, 2)) + ' L' + (x + jitter(n, 3)) + ' ' + (56 + jitter(n, 4));
}
return 'M' + (x0 - 5) + ' ' + (50 + jitter(n, 5)) + ' L' + (x0 + 27) + ' ' + (28 + jitter(n, 6));
}
var n = Number(count.textContent) || 0;
function render() {
var empty = svg.querySelector('.field-tally__empty');
if (n === 0 && !empty) {
var t = document.createElementNS(NS, 'text');
t.setAttribute('class', 'field-tally__empty');
t.setAttribute('x', '120');
t.setAttribute('y', '44');
t.setAttribute('text-anchor', 'middle');
t.textContent = 'Tap to count';
svg.appendChild(t);
} else if (n > 0 && empty) empty.remove();
var marks = svg.querySelectorAll('.field-tally__mark');
var want = Math.min(n, MAX_SHOWN);
for (var i = marks.length; i < want; i++) {
var p = document.createElementNS(NS, 'path');
p.setAttribute('class', 'field-tally__mark');
p.setAttribute('d', mark(i));
p.setAttribute('pathLength', '1');
svg.appendChild(p);
}
for (var j = marks.length - 1; j >= want; j--) marks[j].remove();
// The number rolls in when it changes.
count.textContent = '';
var s = document.createElement('span');
s.textContent = String(n);
count.appendChild(s);
hint.textContent = n > MAX_SHOWN ? 'Board full, still counting' : '';
undo.disabled = n === 0;
}
function add() { n += 1; render(); }
function back() { n = Math.max(0, n - 1); render(); }
board.addEventListener('click', add);
undo.addEventListener('click', back);
return function () {
board.removeEventListener('click', add);
undo.removeEventListener('click', back);
};
}
document.querySelectorAll('.field-tally').forEach(initFieldTally);Goal: Create a "Tally" input.
Visual behavior: A lap counter that keeps tally marks: tap the board and a hand-drawn stroke draws in, four uprights and then a slash across them for every five, while the count rolls up. Undo takes the last one back.
Technique:
- Stroke n goes to group n / 5: uprights 7px apart, and the fifth a diagonal across them. A fixed hash adds a little hand jitter to each end.
- Each new stroke is an SVG path with pathLength 1 that draws in by animating its dashoffset; the count rolls in on a keyframe.
- Thirty strokes fill the board; beyond that it keeps counting and says so.
Accessibility: The board is a real button (labelled Laps) and Undo is a button; the count is aria-live. Under prefers-reduced-motion strokes appear without drawing.
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 "Tally" input.
Visual behavior: A lap counter that keeps tally marks: tap the board and a hand-drawn stroke draws in, four uprights and then a slash across them for every five, while the count rolls up. Undo takes the last one back.
Technique:
- Stroke n goes to group n / 5: uprights 7px apart, and the fifth a diagonal across them. A fixed hash adds a little hand jitter to each end.
- Each new stroke is an SVG path with pathLength 1 that draws in by animating its dashoffset; the count rolls in on a keyframe.
- Thirty strokes fill the board; beyond that it keeps counting and says so.
Accessibility: The board is a real button (labelled Laps) and Undo is a button; the count is aria-live. Under prefers-reduced-motion strokes appear without drawing.
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
A lap counter that keeps tally marks: tap the board and a hand-drawn stroke draws in, four uprights and then a slash across them for every five, while the count rolls up. Undo takes the last one back.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- click, draw, state change
- Techniques
- svg stroke, stroke dashoffset, keyframes
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The board is a real button (labelled Laps) and Undo is a button; the count is aria-live. Under prefers-reduced-motion strokes appear without drawing.