GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

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. Shorten the refresh interval from a day to a second and the same pillar gives you Stock Ticker UI: 9 Live Price Widgets, while dropping a card like 09 onto someone else's page is the job of 9 Social Share Button Design Embed Widgets.

01Year contribution grid

371 dots filling week by week

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.

Year contribution grid
Open in a new tab

01 · Year contribution grid — Measured area 9.524% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="grid"aria-labelanimation-delaylocalStorage
.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; }
}

Why the 1px gap is a border

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

A streak that grows in four steps

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.

Streak flame counter
Open in a new tab

02 · Streak flame counter — Measured area 4.381% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

transform-originsteps(1, end)aria-livelocalStorage
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));
}

Cutting the flame into steps

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, seven dots each

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.

Seven-day dot card
Open in a new tab

03 · Seven-day dot card — Measured area 7.344% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-pressedstroke-dashoffsetgrid-template-columnslocalStorage
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));
}

Inline style versus keyframes

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

A stamp that lands at an angle

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.

Calendar stamp
Open in a new tab

04 · Calendar stamp — Measured area 36.483% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="grid"rotate:focus-visiblelocalStorage
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');
}

Why the columns sit at zero spacing

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, three circumferences

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.

Triple goal rings
Open in a new tab

05 · Triple goal rings — Measured area 5.603% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dasharraystroke-dashoffsetstroke-linecapprefers-reduced-motion
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

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 from the bottom edge

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.

Weekday bar summary
Open in a new tab

06 · Weekday bar summary — Measured area 12.28% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-template-columnstransform-originscaleYaria-live
.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)); }
}

Why 20% of each bar stays

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

Counting minutes to the next reminder

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.

Reminder time picker
Open in a new tab

07 · Reminder time picker — Measured area 6.332% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="switch"aria-checkedaria-pressedlocalStorage
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 fill-mode left off on purpose

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

Badges that flip at a threshold

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.

Badge unlock
Open in a new tab

08 · Badge unlock — Measured area 28.45% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotateYbackface-visibilitytransform-stylegrayscale
.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); }
}

Why only the preview flip is a cut

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 drawn onto a canvas 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.

Daily summary share card
Open in a new tab

09 · Daily summary share card — Measured area 12.913% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

canvasgetContexttoDataURLnavigator.clipboard
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);
}

The fallback when the clipboard refuses

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. A list that grows a new row, like the ones in 9 Activity Feed UI Widgets, sails through the same trap on preview alone.

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

Three variables carry all nine

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

One frame left after motion stops

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.

Never carrying information in color alone

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. The cards, tables, and stat tiles you would set beside this grid on an admin screen are gathered in 9 Dashboard Components.

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.

Enter the archive password

The password is inside this article. You will find it as you read.