Habit Tracker UI: 9 Parts from Grid to Badge
A habit tracker ui is the screen where you tap what you did today and see the shape those taps make.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Year contribution grid
- 02 Streak flame counter
- 03 Seven-day dot card
- 04 Calendar stamp
- 05 Triple goal rings
- 06 Weekday bar summary
- 07 Reminder time picker
- 08 Badge unlock
- 09 Daily summary share card
The order follows how far a record travels before a person reads it. The first four hand the taps straight back: a year unrolled into 371 dots (01), a streak translated into icon size (02), three habits folded into seven dots each (03), and a month you stamp day by day (04). The middle three turn the marks into numbers, stacking goal ratios into three rings (05), standing weekday counts up as bars (06), and counting the minutes to the next reminder (07). The last two send the record outward: a badge that flips once a threshold is crossed (08) and a card that turns today into an image file (09). It is also the order most habit tracker ui design work settles into once the screen is built. Stage colors cycle through three paper, three ink, two yellow, and one orange. If you want the same data drawn as lines and areas, that is nine SVG charts drawn from scratch; if you want the date grid itself, that is nine CSS calendar builds.
01Year contribution grid
371 dots across 53 weeks and 7 days fill in week by week from the left, and pointing at one pops its date and count into a bubble. Intensity comes from a mulberry32 generator seeded with 20260912, so a refresh returns the same grass, and only today's cell is overwritten from localStorage by a button that steps 0 through 4.
.cg__cell {
box-sizing: border-box; width: 7px; height: 7px; flex: 0 0 auto;
border: 1px solid transparent; border-radius: 50%;
background: rgba($subject-cream, .10); background-clip: padding-box;
}
.cg.is-demo .cg__cell {
animation-name: cgWave; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--c) * 12ms);
}
@keyframes cgWave {
0% { transform: scale(.15); opacity: .3; }
14% { transform: scale(1); opacity: 1; }
86% { transform: scale(1); opacity: 1; }
100% { transform: scale(.15); opacity: .3; }
}
The 1px between dots is a transparent border, not a gap, because fitting 53 columns into 424px calls for spacing that falls off the 4px scale, and a border belongs to the cell itself rather than to the space between cells, so box-sizing: border-box keeps the scale intact.
02Streak flame counter
Checking today adds one day and updates the best record, while breaking the streak sends the number to zero and leaves the best record standing. The flame never feeds the day count straight into scale; it grows through four steps with thresholds at 30, 14, 3, and 1 day, so a hundred-day streak still does not swallow the screen.
function stepOf(n) {
return n >= 30 ? 4 : n >= 14 ? 3 : n >= 3 ? 2 : n >= 1 ? 1 : 0;
}
function paint(broken) {
numEl.textContent = String(state.streak);
bestEl.textContent = BEST_TEXT.replace('{b}', String(state.best));
flame.setAttribute('class', 'sc__flame sc__flame--s' + stepOf(state.streak));
sayEl.textContent = broken
? SAY_OFF.replace('{b}', String(state.best))
: SAY_ON.replace('{n}', String(state.streak));
}
The flame jumps between steps on steps(1, end), so a size change never leaves a half-transparent middle frame, and the seven weekly dots and the halo fill the frames in between, which is why this one moves in 23 of 23 frames, as dense as anything in the nine.
03Seven-day dot card
Three habit rows each carry seven dots, and pressing one fills it while the ring on the right winds by the same amount. Each dot is a real <button>, so aria-pressed carries the state, and the circumference goes into stroke-dasharray after JS works out 2 * Math.PI * 26.
var CIRC = 2 * Math.PI * 26;
function paint() {
var n = total(), all = marks.length * DAYS;
var p = n / all;
ring.style.strokeDasharray = String(CIRC);
ring.style.strokeDashoffset = String(CIRC * (1 - p));
pctEl.textContent = Math.round(p * 100) + '%';
sayEl.textContent = SAY_TEXT.replace('{n}', String(n));
}
JS writes the offset as an inline style while the preview writes it in @keyframes, and since animation declarations outrank inline styles, the two never fight: press the fourth dot and the readout goes from 62% to 67% as the offset moves from 62.2335 to 54.4543.
04Calendar stamp
Press a day and the stamp lands at an angle, traveling from rotate(-42deg) scale(.3) to rotate(-12deg) scale(1). Consecutive days tie together into one band because each stamped cell drops the corner radius on the side that continues, and the tie breaks whenever the row does.
function paintCell(day) {
var b = cells[day];
var on = has(day);
var col = (day - 1) % COLS;
var left = on && col > 0 && has(day - 1);
var right = on && col < COLS - 1 && has(day + 1);
b.className = 'cs__cell' + (on ? ' is-on' : '') + (left ? ' is-left' : '') + (right ? ' is-right' : '');
b.setAttribute('aria-pressed', on ? 'true' : 'false');
}
Horizontal spacing between cells is zero because even a couple of pixels would cut the band at the seam and hide the fact that the days run together, and pressing day 10 turns day 11 into cs__cell is-on is-left is-right with both sides joined.
05Triple goal rings
Three rings of radius 50, 38, and 26 each wind along their own circumference, and shards burst out once when all three close. Each ring needs its own 2 * Math.PI * RADII[i], because reusing a single circumference sends the inner ring around twice.
function paint() {
var closed = 0;
for (var i = 0; i < rings.length; i++) {
var p = Math.min(done[i] / GOALS[i], 1);
var circ = 2 * Math.PI * RADII[i];
rings[i].style.strokeDasharray = String(circ);
rings[i].style.strokeDashoffset = String(circ * (1 - p));
pcts[i].textContent = Math.round(p * 100) + '%';
if (p >= 1) { closed++; }
}
sayEl.textContent = SAY_TEXT.replace('{n}', String(closed));
return closed === rings.length;
}
Replaying the burst means removing the class, forcing a reflow with void root.offsetWidth, and then adding it back, since a remove-and-add in the same tick gets folded into one change and the animation simply never restarts.
06Weekday bar summary
Seven bars grow from their bottom edge to show the count for each weekday, and only the bars past the three-a-day goal line change color. Columns are split evenly by repeat(7, 1fr), and the bars rise on transform-origin: bottom with scaleY(var(--v)) so height itself is never touched.
.wb__cols { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-2; }
.wb__slot { display: block; width: 100%; height: 96px; border-radius: $r-sm; background: rgba($subject-ink, .08); }
.wb__bar {
display: block; width: 100%; height: 100%; border-radius: $r-sm;
background: rgba($subject-ink, .3);
transform-origin: bottom; transform: scaleY(var(--v, 0));
}
@keyframes wbGrow {
0% { transform: scaleY(calc(var(--v, 0) * .2)); }
24% { transform: scaleY(var(--v, 0)); }
76% { transform: scaleY(var(--v, 0)); }
90% { transform: scaleY(calc(var(--v, 0) * .2)); }
100% { transform: scaleY(calc(var(--v, 0) * .2)); }
}
The preview keeps 20% of each bar instead of dropping it to the floor, because whichever of the 24 frames becomes the cover image has to show all seven bars before it reads as a chart.
07Reminder time picker
Pick a time chip, flip the switch on, and the wait until the next reminder counts down in minutes. A time whose difference has already gone to zero or below rolls over to tomorrow, so choosing 06:00 at ten at night reads as eight hours until the next morning rather than a negative number.
function minutesLeft(hhmm) {
var parts = hhmm.split(':');
var now = new Date();
var at = new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(parts[0]), Number(parts[1]), 0, 0);
var diff = Math.round((at.getTime() - now.getTime()) / 60000);
return diff <= 0 ? diff + 24 * 60 : diff;
}
The hop that lights one chip at a time deliberately carries no animation-fill-mode, because the picture that belongs in the delay window is the chip's authored state, and backwards would light up every chip that has not had its turn yet.
08Badge unlock
The seven-, thirty-, and hundred-day badges sit locked in gray until the count crosses the line, then flip front to back and take on their color. The two faces stack under transform-style: preserve-3d with backface-visibility: hidden on each, and the locked face pairs filter: grayscale(1) with a written label so the state reads without color.
.bu__inner {
position: relative; display: block; width: 100%; height: 100%;
transform-style: preserve-3d; transition: transform $dur-base $easing;
}
.bu__badge.is-open .bu__inner { transform: rotateY(180deg); }
.bu__face {
position: absolute; left: 0; top: 0; width: 100%; height: 100%;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: $sp-1;
border-radius: $r-card; backface-visibility: hidden; overflow: hidden;
}
@keyframes buFlip {
0% { transform: rotateY(0deg); }
16% { transform: rotateY(180deg); }
78% { transform: rotateY(180deg); }
88% { transform: rotateY(0deg); }
100% { transform: rotateY(0deg); }
}
A real click still runs a smooth 300ms transition, and only the preview flip is a cut, because the width of a face under rotateY follows a cosine, the per-frame change always peaks near 90 degrees, and the cover image would otherwise be an edge-on sliver every single time.
09Daily summary share card
Today's record is drawn onto a canvas as one card, and a single button hands that image to the clipboard. The card is painted once when the page opens, so you can see what is being shared before pressing anything, and it is drawn at 400 by 224 into a 200 by 112 box so the text survives a high-density screen.
function copyCard() {
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write && canvas.toBlob) {
canvas.toBlob(function (blob) {
var item = new window.ClipboardItem({ 'image/png': blob });
navigator.clipboard.write([item]).then(function () { flash(COPIED_TEXT); }, download);
}, 'image/png');
return;
}
download();
}
function download() {
var a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'habit-card.png';
a.click();
flash(SAVED_TEXT);
}
Writing an image to the clipboard fails outright where permission is refused or ClipboardItem is missing, so a toDataURL link sits beside it as a download path, and the status line says which route the click actually took.
Where it breaks: the preview passes and it still will not move in your hand
The preview and your finger run different code
Movement in these nine comes in two copies. What lands in the gallery is a CSS keyframe loop inside .is-demo; what your finger touches is JS changing state. The gates only measure the first, so the second can be dead while the numbers stay healthy. That is exactly what happened to the flame in 02. The step class was assigned to flame.className, but on an SVG element that property is an SVGAnimatedString rather than a string, so the assignment was silently dropped, and the render still reported 4.67% swept area, 68.7 intensity, and 23 of 23 moved frames (pre-fix values live in run/297/_수리전실측.json). Only after switching to setAttribute did a click actually move the class to sc__flame sc__flame--s2, and breaking the streak to sc__flame sc__flame--s0.
Color has to be measured after compositing
So every one of the nine was opened with .is-demo stripped and driven by a script that really clicks (run/297/_probe/interact.py). Color went through the same treatment. Composing computed colors up the parent chain turned up eight places below WCAG AA, and the most stubborn of them was the grid legend. Against the level-two color neither choice clears the bar at 9px, cream at 4.06:1 and ink at 4.21:1, so the digits had to move outside the chip. The password for the archive holding all nine is e2eay99r, and all twenty-six places, measured again by the same rule, now clear AA.
| Measured | Text | Background | Ratio | Verdict |
|---|---|---|---|---|
| 01 legend digit | #fff7e6 | #17141a | 17.11:1 | AA |
| 02 status line | #17141a | #ff4d1f | 5.5:1 | AA |
| 04 unstamped date | #4a3e22 | #ffd23f | 7.27:1 | AA |
| 06 chart caption | #5d5b5f | #ffffff | 6.76:1 | AA |
| 08 locked label | #948f88 | #2a262a | 4.63:1 | AA |
Three variants and accessibility
Changing the three variables at the top of the SCSS carries all nine along.
| Name | Value changed | Feel |
|---|---|---|
| duration | $duration 2s to 1s |
The filling rhythm doubles in speed |
| easing | $easing $ease-spring to $ease-pop |
Stamps and badges bounce once more |
| color | $color $stage-yellow to $subject-mint |
The grid moves to a fresher key |
Under prefers-reduced-motion: reduce, each of the nine leaves something different behind. 01 stops the wave and holds fully grown grass, 02 freezes the flame, halo, and dots in their current state, 03 keeps the filled dots and the wound ring, 04 keeps the stamped month, 05 drops the burst first and keeps only the rings, 06 leaves the bars standing at their present values, 07 holds the chosen time, 08 holds the current unlock state, and 09 holds the card. Every declaration inside that block carries !important, because a selector qualified by .is-demo wins on specificity first.
The rule against carrying information in color alone applies to all nine as well. 01 prints the digits 0 through 4 next to the legend and puts the date and count in each cell's aria-label, 05 writes a name and a percentage beside every ring, 06 prints the count above each bar, and 08 lays a word over the grayscale. Anywhere a number changes, a sentence is rewritten into a role="status" region with aria-live="polite". The references are the MDN page on ARIA live regions and the WCAG 2.2 understanding page for use of color.
FAQ
Is it fine to keep the record without a server?
All nine use nothing but localStorage. Reads and writes are wrapped in try/catch, so a private window or a browser with storage blocked falls back to the defaults instead of taking the screen down with it. The record does not follow you to another browser, though, so for a product meant to run on several devices, treat this code as the point where the same value also goes out to your server.
Where do I plug a real year of data into the grid?
Strip out the seeded random fill and hand that same spot an array of per-day counts. A cell's index is the week number times seven plus the weekday, and the last cell is today, so as long as the array you hand in is sorted with today at the end, the rest of the math stays as it is.
Rings or bars for a dashboard?
A spot that only has to answer whether the day is finished is better served by the rings (05); a spot where weekdays need comparing is better served by the bars (06). Angles make the difference between 10% and 15% hard to read, while bars sit on one axis where a difference in length is the comparison. Lifted on its own, 03 also works as a habit tracker ui template: swap the habit names and the row count and it drops straight in. For more on circular gauges, nine circular progress widgets dig into that axis on their own.