Tally
import { useEffect, useRef, useState } from 'react';
const W = 240;
const MAX = 20;
const TOP = 8;
const BOT = 40;
const STEP = (W - 12) / MAX;
const pos = (v: number) => 6 + v * STEP;
// A steady hand, not a ruler: every mark gets its own small wobble.
const wob = (k: number, n: number) => {
const s = Math.sin(k * 12.9898 + n * 78.233) * 43758.5453;
return (s - Math.floor(s)) * 2 - 1;
};
function markPath(k: number) {
if (k % 5) {
const x = pos(k) - STEP / 2;
const [x0, x1] = [x + wob(k, 1) * 1.2, x + wob(k, 2) * 1.6];
return `M${x0} ${TOP + 1 + wob(k, 3) * 1.5}Q${(x0 + x1) / 2 + wob(k, 4) * 1.4} ${(TOP + BOT) / 2} ${x1} ${BOT + wob(k, 5) * 1.5}`;
}
// Every fifth mark strikes through the four before it.
const [g0, g1] = [pos(k - 4) - STEP / 2 - 5, pos(k - 1) - STEP / 2 + 5];
return `M${g0} ${BOT - 3 + wob(k, 6) * 2}Q${(g0 + g1) / 2} ${(TOP + BOT) / 2 + 3 + wob(k, 7) * 2} ${g1} ${TOP + 5 + wob(k, 8) * 2}`;
}
const MARKS = Array.from({ length: MAX }, (_, i) => ({ k: i + 1, d: markPath(i + 1) }));
const css = `
.slider-tally { position: relative; width: ${W}px; height: 72px; }
.slider-tally__svg { display: block; width: ${W}px; height: 48px; overflow: visible; }
.slider-tally__slot { fill: light-dark(#dbdde1, #2e3035); transition: opacity 0.2s; }
.slider-tally__mark {
fill: none;
stroke: light-dark(#42454c, #e5e6e9);
stroke-width: 2.2;
stroke-linecap: round;
stroke-dasharray: 1 1;
stroke-dashoffset: 1;
opacity: 0;
transition: stroke-dashoffset 0.22s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.22s;
}
.slider-tally__mark--on { stroke-dashoffset: 0; opacity: 1; }
.slider-tally__pen {
position: absolute;
top: 2px;
left: calc(var(--slider-tally-x) - 1px);
width: 2px;
height: 42px;
border-radius: 1px;
background: light-dark(#6d7179, #7d8087);
transform-origin: 50% 100%;
transition: left 0.18s cubic-bezier(0.3, 1.3, 0.5, 1), transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1), background-color 0.2s;
}
.slider-tally:has(.slider-tally__input:active) .slider-tally__pen { transform: rotate(14deg); background: light-dark(#373a40, #f1f2f3); }
.slider-tally:has(.slider-tally__input:focus-visible) .slider-tally__pen { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-tally__count { position: absolute; left: 0; top: 54px; font-size: 13px; color: light-dark(#5f626a, #a7abb2); }
.slider-tally__count b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-tally__input { position: absolute; left: 0; top: 0; width: 100%; height: 48px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-tally__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 48px; }
.slider-tally__input::-moz-range-thumb { width: 12px; height: 48px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-tally__mark, .slider-tally__pen { transition: none; }
}
`;
export function TallySlider() {
const [v, setV] = useState(12);
const prev = useRef(v);
const from = prev.current;
useEffect(() => {
prev.current = v;
}, [v]);
return (
<>
<style>
{css}
</style>
<div className="slider-tally" style={{ '--slider-tally-x': `${pos(v)}px` } as React.CSSProperties}>
<svg className="slider-tally__svg" aria-hidden="true">
{MARKS.filter((m) => m.k % 5).map((m) => (
<circle key={m.k} className="slider-tally__slot" cx={pos(m.k) - STEP / 2} cy={BOT + 6} r={1.2} style={{ opacity: m.k <= v ? 0 : 1 }} />
))}
{MARKS.map((m) => {
// New marks draw in order from the last one; removed ones rub out from the end back.
const delay = m.k > from ? (m.k - from - 1) * 55 : (from - m.k) * 35;
return (
<path
key={m.k}
className={`slider-tally__mark${m.k <= v ? ' slider-tally__mark--on' : ''}`}
d={m.d}
pathLength={1}
style={{ transitionDelay: `${Math.max(0, delay)}ms` }}
/>
);
})}
</svg>
<span className="slider-tally__pen" aria-hidden="true" />
<p className="slider-tally__count" aria-hidden="true">
<b>{v}</b> {v === 1 ? 'day' : 'days'} in a row
</p>
<input
className="slider-tally__input"
type="range"
min={0}
max={MAX}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Streak"
aria-valuetext={`${v} ${v === 1 ? 'day' : 'days'}`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderTally(el) on the mounted element instead.
<div class="slider-tally" style="--slider-tally-x:142.8px">
<svg class="slider-tally__svg" aria-hidden="true">
<circle class="slider-tally__slot" cx="11.7" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="23.1" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="34.5" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="45.9" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="68.7" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="80.1" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="91.5" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="102.9" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="125.7" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="137.1" cy="46" r="1.2" style="opacity:0"></circle>
<circle class="slider-tally__slot" cx="148.5" cy="46" r="1.2" style="opacity:1"></circle>
<circle class="slider-tally__slot" cx="159.9" cy="46" r="1.2" style="opacity:1"></circle>
<circle class="slider-tally__slot" cx="182.7" cy="46" r="1.2" style="opacity:1"></circle>
<circle class="slider-tally__slot" cx="194.1" cy="46" r="1.2" style="opacity:1"></circle>
<circle class="slider-tally__slot" cx="205.5" cy="46" r="1.2" style="opacity:1"></circle>
<circle class="slider-tally__slot" cx="216.9" cy="46" r="1.2" style="opacity:1"></circle>
<path class="slider-tally__mark slider-tally__mark--on" d="M12.276 8.76Q12.217 24 10.336 38.967" pathLength="1" style="transition-delay:385ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M23.255 8.389Q23.068 24 22.666 38.835" pathLength="1" style="transition-delay:350ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M34.391 10.21Q32.878 24 33.973 40.931" pathLength="1" style="transition-delay:315ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M45.354 9.353Q45.232 24 47.31 39.097" pathLength="1" style="transition-delay:280ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M6.7 35.343Q28.8 26.515 50.9 11.271" pathLength="1" style="transition-delay:245ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M69.307 9.564Q70.441 24 68.92 40.541" pathLength="1" style="transition-delay:210ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M80.109 7.863Q80.009 24 80.313 41.295" pathLength="1" style="transition-delay:175ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M91.556 10.034Q90.257 24 91.739 40.516" pathLength="1" style="transition-delay:140ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M102.952 7.677Q103.035 24 103.118 41.426" pathLength="1" style="transition-delay:105ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M63.7 37.956Q85.8 25.588 107.9 13.448" pathLength="1" style="transition-delay:70ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M125.922 9.723Q127.14 24 127.031 40.083" pathLength="1" style="transition-delay:35ms"></path>
<path class="slider-tally__mark slider-tally__mark--on" d="M137.367 9.396Q138.1 24 138.099 41.264" pathLength="1" style="transition-delay:0ms"></path>
<path class="slider-tally__mark" d="M148.263 7.899Q146.554 24 147.532 40.077" pathLength="1" style="transition-delay:0ms"></path>
<path class="slider-tally__mark" d="M160.738 7.69Q160.493 24 160.693 39.459" pathLength="1" style="transition-delay:55ms"></path>
<path class="slider-tally__mark" d="M120.7 38.342Q142.8 27.264 164.9 13.604" pathLength="1" style="transition-delay:110ms"></path>
<path class="slider-tally__mark" d="M182.514 8.971Q182.618 24 184.163 40.118" pathLength="1" style="transition-delay:165ms"></path>
<path class="slider-tally__mark" d="M193.471 8.822Q193.62 24 193.959 39.899" pathLength="1" style="transition-delay:220ms"></path>
<path class="slider-tally__mark" d="M206.67 7.976Q206.296 24 206.862 38.617" pathLength="1" style="transition-delay:275ms"></path>
<path class="slider-tally__mark" d="M217.404 7.579Q218.264 24 216.904 41.2" pathLength="1" style="transition-delay:330ms"></path>
<path class="slider-tally__mark" d="M177.7 37.33Q199.8 25.869 221.9 14.947" pathLength="1" style="transition-delay:385ms"></path>
</svg>
<span class="slider-tally__pen" aria-hidden="true"></span>
<p class="slider-tally__count" aria-hidden="true">
<b>12</b>
days in a row
</p>
<input class="slider-tally__input" type="range" min="0" max="20" aria-label="Streak" aria-valuetext="12 days" value="12"/>
</div>.slider-tally { position: relative; width: 240px; height: 72px; }
.slider-tally__svg { display: block; width: 240px; height: 48px; overflow: visible; }
.slider-tally__slot { fill: light-dark(#dbdde1, #2e3035); transition: opacity 0.2s; }
.slider-tally__mark {
fill: none;
stroke: light-dark(#42454c, #e5e6e9);
stroke-width: 2.2;
stroke-linecap: round;
stroke-dasharray: 1 1;
stroke-dashoffset: 1;
opacity: 0;
transition: stroke-dashoffset 0.22s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.22s;
}
.slider-tally__mark--on { stroke-dashoffset: 0; opacity: 1; }
.slider-tally__pen {
position: absolute;
top: 2px;
left: calc(var(--slider-tally-x) - 1px);
width: 2px;
height: 42px;
border-radius: 1px;
background: light-dark(#6d7179, #7d8087);
transform-origin: 50% 100%;
transition: left 0.18s cubic-bezier(0.3, 1.3, 0.5, 1), transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1), background-color 0.2s;
}
.slider-tally:has(.slider-tally__input:active) .slider-tally__pen { transform: rotate(14deg); background: light-dark(#373a40, #f1f2f3); }
.slider-tally:has(.slider-tally__input:focus-visible) .slider-tally__pen { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-tally__count { position: absolute; left: 0; top: 54px; font-size: 13px; color: light-dark(#5f626a, #a7abb2); }
.slider-tally__count b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-tally__input { position: absolute; left: 0; top: 0; width: 100%; height: 48px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-tally__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 48px; }
.slider-tally__input::-moz-range-thumb { width: 12px; height: 48px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-tally__mark, .slider-tally__pen { transition: none; }
}function initSliderTally(root) {
var W = 240;
var MAX = 20;
var STEP = (W - 12) / MAX;
var input = root.querySelector('.slider-tally__input');
var marks = root.querySelectorAll('.slider-tally__mark');
var slots = root.querySelectorAll('.slider-tally__slot');
var count = root.querySelector('.slider-tally__count');
var from = +input.value;
function render() {
var v = +input.value;
var days = v === 1 ? 'day' : 'days';
root.style.setProperty('--slider-tally-x', 6 + v * STEP + 'px');
// New marks draw in order from the last one; removed ones rub out from the end back.
marks.forEach(function (m, i) {
var k = i + 1;
var delay = k > from ? (k - from - 1) * 55 : (from - k) * 35;
m.style.transitionDelay = Math.max(0, delay) + 'ms';
m.classList.toggle('slider-tally__mark--on', k <= v);
});
// Empty places are marked with a dot; every fifth place is the strike, which has none.
var i = 0;
for (var k = 1; k <= MAX; k++) {
if (k % 5 === 0) continue;
slots[i++].style.opacity = k <= v ? 0 : 1;
}
count.innerHTML = '<b>' + v + '</b> ' + days + ' in a row';
input.setAttribute('aria-valuetext', v + ' ' + days);
from = v;
}
input.addEventListener('input', render);
return function () {
input.removeEventListener('input', render);
};
}
document.querySelectorAll('.slider-tally').forEach(initSliderTally);Goal: Create a "Tally" slider.
Visual behavior: A streak counter drawn as tally marks: each mark draws itself in with a pen stroke, every fifth strikes through the four before it, and going back rubs them out from the end.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- All twenty marks are SVG paths with pathLength="1", drawn by moving stroke-dashoffset between 1 and 0; each has its own small wobble from a seeded pseudo-random function, so the hand looks steady but not ruled. Every fifth path is the diagonal strike across its group.
- When the value jumps, each changing mark gets a transition delay from its distance to the old value, so marks draw in order and rub out from the end back. A slim pen sits after the last mark and tilts while you drag.
Accessibility: A native range input with an aria-label and an aria-valuetext in days; keyboard focus rings the pen. Under prefers-reduced-motion marks appear and disappear 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" slider.
Visual behavior: A streak counter drawn as tally marks: each mark draws itself in with a pen stroke, every fifth strikes through the four before it, and going back rubs them out from the end.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- All twenty marks are SVG paths with pathLength="1", drawn by moving stroke-dashoffset between 1 and 0; each has its own small wobble from a seeded pseudo-random function, so the hand looks steady but not ruled. Every fifth path is the diagonal strike across its group.
- When the value jumps, each changing mark gets a transition delay from its distance to the old value, so marks draw in order and rub out from the end back. A slim pen sits after the last mark and tilts while you drag.
Accessibility: A native range input with an aria-label and an aria-valuetext in days; keyboard focus rings the pen. Under prefers-reduced-motion marks appear and disappear 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 streak counter drawn as tally marks: each mark draws itself in with a pen stroke, every fifth strikes through the four before it, and going back rubs them out from the end.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, draw, delay stagger
- Techniques
- svg, stroke dashoffset, transition delay stagger
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in days; keyboard focus rings the pen. Under prefers-reduced-motion marks appear and disappear without drawing.