GODRICH175

습관 트래커 UI 9종 — 잔디 격자부터 공유 카드까지

습관 트래커는 오늘 한 일을 눌러 기록하고, 그 기록이 쌓인 모양을 한 화면에서 보여 주는 UI입니다. 여기 실은 아홉 개는 라이브러리 없이 바닐라 JS 와 SCSS 로만 짰고, 누르면 그 자리에서 값이 바뀌어 localStorage 에 남습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개를 이 순서로 늘어놓은 기준은 기록이 사람에게 도착하는 거리입니다. 앞의 넷은 누른 흔적을 그대로 보여 주는 판 — 1년 치를 점 371개로 펼친 격자(01), 연속 일수를 아이콘 크기로 번역한 불꽃(02), 습관 세 줄을 7일 점으로 접은 카드(03), 날짜 칸에 도장을 찍는 달력(04)입니다. 가운데 셋은 흔적을 숫자로 요약하는 판으로, 목표 대비 비율을 세 겹 링으로 겹치고(05) 요일별 개수를 막대로 세우고(06) 다음 알림까지 남은 시간을 분으로 셉니다(07). 마지막 둘은 기록을 밖으로 내보내는 판 — 조건을 넘으면 뒤집히는 배지(08)와 오늘 요약을 그림 파일로 만드는 카드(09)입니다. 습관 트래커 앱을 새로 짤 때 화면에 먼저 놓이는 순서이기도 합니다. 무대 색은 페이퍼 셋·잉크 셋·옐로 둘·오렌지 하나로 돌렸습니다. 같은 데이터를 선과 면으로 그리는 쪽은 SVG 그래프 그리기 9종에, 날짜 칸 자체를 짜는 법은 CSS 달력 만들기 9종에 있습니다.

011년 잔디 격자

53주 × 7일 점 371개가 왼쪽 주부터 차례로 차오르고, 점 위에 손을 올리면 그 날짜와 횟수가 말풍선에 뜹니다. 강도는 시드를 20260912 로 고정한 mulberry32 난수가 만들기 때문에 새로 고쳐도 같은 잔디가 나오고, 오늘 칸만 localStorage 값으로 덮어쓰기 때문에 버튼을 누르면 0에서 4까지 한 단계씩 오릅니다.

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; }
}

점 사이를 벌리는 1px 은 gap 이 아니라 투명 border 인데, 53칸을 424px 안에 넣으려면 칸 간격이 4px 눈금에 맞지 않고, 테두리는 간격이 아니라 칸 자신의 일부여서 box-sizing: border-box 와 함께 쓰면 눈금이 깨지지 않기 때문입니다.

02연속 일수 불꽃

「오늘 체크」를 누르면 연속 일수가 하루 늘면서 최장 기록도 함께 갱신되고, 「연속 끊기」는 숫자만 0으로 되돌릴 뿐 최장 기록은 남깁니다. 불꽃은 일수를 그대로 scale 에 넣지 않고 30·14·3·1일을 경계로 네 단계로만 커지기 때문에 100일이 되어도 화면을 덮지 않습니다.

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));
}

불꽃 단계를 steps(1, end) 로 뛰게 해 크기가 어중간한 중간 프레임을 만들지 않게 하고 그 사이 프레임은 주간 점 일곱 개와 후광이 채우는데, 덕분에 이 판은 움직인 프레임 23/23 으로 아홉 개 중 가장 촘촘한 축입니다.

03습관 7일 점 카드

습관 세 줄에 각각 7일 치 점이 붙어 있고, 점을 누르면 그 점이 채워지면서 오른쪽 링이 그만큼 감깁니다. 점은 진짜 <button> 이라 aria-pressed 로 상태가 그대로 읽히고, 링 둘레는 JS 가 2 * Math.PI * 26 으로 계산해 stroke-dasharray 에 넣습니다.

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));
}

JS 는 인라인 style 로 오프셋을 쓰고 미리보기는 @keyframes 로 쓰는데, 애니메이션 선언이 인라인 스타일보다 우선순위가 높아 둘이 부딪치지 않고, 네 번째 점을 눌러 보면 62%에서 67%로, 오프셋이 62.2335 에서 54.4543 으로 바뀝니다.

04달력 도장 찍기

달력 칸을 누르면 도장이 rotate(-42deg) scale(.3) 에서 rotate(-12deg) scale(1) 로 비스듬히 내려앉습니다. 이어진 날짜는 칸 배경의 모서리 반경을 한쪽씩 0으로 만들어 한 줄 띠로 묶이고, 줄이 바뀌면 이음을 끊습니다.

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');
}

칸 사이 가로 간격은 0인데 몇 px 이라도 주면 띠가 그 틈에서 끊겨 이어져 있다는 사실 자체가 보이지 않고, 10일을 눌러 보면 11일 칸이 cs__cell is-on is-left is-right 가 되어 양쪽이 모두 이어집니다.

05삼중 목표 링

반지름 50·38·26 인 세 링이 각자의 둘레로 감기고, 셋이 다 닫히면 조각 여덟 개가 한 번 터집니다. 링마다 둘레가 다르므로 2 * Math.PI * RADII[i] 를 링별로 따로 계산해야 하고, 하나로 쓰면 안쪽 링이 두 바퀴를 돌아 버립니다.

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;
}

폭발을 다시 돌리려면 클래스를 떼고 void root.offsetWidth 로 리플로를 강제한 뒤 다시 붙여야 하는데, 떼자마자 붙이면 브라우저가 두 변경을 한 번에 합쳐 애니메이션이 다시 시작되지 않습니다.

06요일별 막대 요약

막대 일곱 개가 바닥에서 자라 요일별 완료 수를 세우고, 3회 목표선을 넘은 막대만 색이 달라집니다. 칸은 repeat(7, 1fr) 로 균등 분할하고 막대는 transform-origin: bottomscaleY(var(--v)) 로만 키워 높이를 건드리지 않습니다.

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)); }
}

미리보기에서 막대를 바닥까지 내리지 않고 20%를 남긴 것은 24장 중 어느 장이 대표 이미지로 뽑혀도 일곱 막대가 다 보여야 차트로 읽히기 때문입니다.

07리마인더 시간 고르기

시각 칩을 고르고 스위치를 켜면 다음 알림까지 남은 시간을 분 단위로 셉니다. 차이가 0 이하가 되는 시각, 곧 오늘 이미 지난 시각은 하루를 더해 내일로 넘기기 때문에 밤 열 시에 06:00 을 고르면 음수가 아니라 이튿날 아침까지의 8시간으로 나옵니다.

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;
}

칩이 하나씩 켜지는 미리보기에는 animation-fill-mode 를 일부러 안 붙였는데, 지연 구간에 보여야 할 그림이 칩의 기본 상태 그대로라 backwards 를 주면 차례가 안 온 칩이 전부 켜진 채로 시작하기 때문입니다.

08배지 잠금 해제

7일·30일·100일 배지가 회색으로 잠겨 있다가 일수가 그 선을 넘으면 앞뒤로 뒤집히며 색을 되찾습니다. 앞뒤 두 면은 transform-style: preserve-3d 로 겹치고 각각 backface-visibility: hidden 이며, 잠김 면은 filter: grayscale(1) 위에 「잠김」 글자를 함께 적어 색 없이도 읽힙니다.

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); }
}

실제 클릭은 300ms transition 으로 부드럽게 돌지만 미리보기의 뒤집기는 컷으로 끊었는데, rotateY 로 돌아가는 면의 폭은 코사인이라 프레임당 변화가 90도 부근에서 늘 최대가 되어 그대로 두면 대표 이미지가 매번 선 한 줄짜리 모서리 뷰로 뽑힙니다.

09오늘 요약 공유 카드

오늘 기록을 canvas 에 그려 카드 한 장으로 만들고, 버튼 한 번에 그 그림을 클립보드로 보냅니다. 카드는 페이지가 열릴 때 한 번 그려 두기 때문에 버튼을 누르기 전에도 무엇을 공유하는지 보이고, 400 × 224 로 그려 200 × 112 에 얹으므로 고해상도 화면에서도 글자가 뭉개지지 않습니다.

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);
}

클립보드 이미지 쓰기는 권한이 거부되거나 ClipboardItem 이 없는 환경에서 그대로 실패하므로 toDataURL 로 만든 링크를 눌러 내려받는 길을 같이 두고 어느 길로 갔는지를 상태 문장에 다르게 적었습니다.

어디서 깨지는가 — 미리보기는 통과하는데 손에서 안 움직인다

미리보기와 손이 서로 다른 코드를 탄다

이 아홉 개의 움직임은 두 벌입니다. 격자에 박히는 미리보기는 .is-demo 안의 CSS 키프레임이고, 사람이 누르는 경로는 JS 가 상태를 바꾸는 코드입니다. 게이트가 재는 것은 앞의 것뿐이라 뒤의 것이 죽어 있어도 측정값은 멀쩡합니다. 실제로 02의 불꽃이 그랬습니다. 단계 클래스를 flame.className 에 대입했는데 SVG 요소의 그 속성은 문자열이 아니라 SVGAnimatedString 이라 대입이 조용히 무시됐고, 그 사이에도 누적면적 4.67%·강도 68.7·움직인 프레임 23/23 이 그대로 나왔습니다(고치기 전 값은 run/297/_수리전실측.json). setAttribute 로 바꾸고 나서야 클릭 뒤 클래스가 sc__flame sc__flame--s2 로, 연속을 끊으면 sc__flame sc__flame--s0 으로 바뀌는 것이 잡혔습니다.

색은 합성한 뒤에 재야 한다

그래서 아홉 개 전부를 .is-demo 를 뗀 상태로 열어 실제로 눌러 보는 스크립트를 따로 돌렸습니다(run/297/_probe/interact.py). 색도 같은 방법으로 쟀습니다. 컴퓨티드 색을 부모까지 합성해 대비를 계산했더니 여덟 군데가 WCAG AA 에 못 미쳤고, 그중 가장 손쓰기 어려웠던 곳은 잔디 범례였습니다. 2단계 색 위에서는 크림 4.06:1, 잉크 4.21:1 로 어느 글자색도 9px 기준을 못 넘어 숫자를 칩 밖으로 빼야 했습니다. 아홉 개 파일이 든 압축 파일의 비밀번호는 e2eay99r 이고, 같은 잣대로 다시 잰 스물여섯 자리는 지금 전부 AA 를 넘습니다.

잰 곳 글자 배경 대비 판정
01 범례 숫자 #fff7e6 #17141a 17.11:1 AA
02 상태 문장 #17141a #ff4d1f 5.5:1 AA
04 안 찍힌 날짜 #4a3e22 #ffd23f 7.27:1 AA
06 차트 설명 #5d5b5f #ffffff 6.76:1 AA
08 잠김 글자 #948f88 #2a262a 4.63:1 AA

변형 세 가지와 접근성

SCSS 맨 위 변수 세 개만 바꾸면 아홉 개가 함께 따라옵니다.

이름 바꾸는 값 느낌
duration $duration 2s → 1s 채워지는 리듬이 두 배로 빨라집니다
easing $easing $ease-spring$ease-pop 도장과 배지가 한 번 더 튑니다
color $color $stage-yellow$subject-mint 잔디 격자가 상큼한 쪽으로 갑니다

prefers-reduced-motion: reduce 에서는 아홉 개가 각각 다른 것을 남깁니다. 01은 물결을 끄고 잔디가 다 자란 한 장을, 02는 불꽃·후광·점을 세운 지금 상태를, 03은 채워진 점과 감긴 링을, 04는 도장이 찍힌 달력을, 05는 폭발을 빼고 링만, 06은 지금 값 그대로 선 막대를, 07은 고른 시각 한 장을, 08은 지금 열림 상태를, 09는 카드 한 장을 남깁니다. 그 블록 안 선언에는 하나씩 !important 를 달아 두었는데, .is-demo 가 붙은 선택자가 특이도에서 먼저 이기기 때문입니다.

색으로만 정보를 주지 않는다는 규칙도 아홉 개 전부에 적용했습니다. 01은 범례에 0부터 4까지 숫자를 함께 찍고 칸마다 aria-label 에 날짜와 횟수를 넣었고, 05는 링 옆에 이름과 퍼센트를 적었고, 06은 막대 위에 개수를 적었고, 08은 회색조 위에 「잠김」 글자를 얹었습니다. 숫자가 바뀌는 자리는 전부 role="status"aria-live="polite" 영역에 문장으로 다시 씁니다. 근거는 MDN 의 ARIA 라이브 영역 문서WCAG 2.2 의 색 사용 이해 문서입니다.

FAQ

기록을 서버 없이 저장해도 괜찮나요

아홉 개 전부 localStorage 하나만 씁니다. 읽기와 쓰기를 try/catch 로 감싸 두었기 때문에 시크릿 창이나 저장소를 막아 둔 브라우저에서도 화면이 죽지 않고 기본값으로 그대로 돌아갑니다. 다만 브라우저를 바꾸면 기록이 따라오지 않으니, 여러 기기에서 쓸 서비스라면 이 코드를 그대로 두고 같은 값을 서버에도 한 벌 보내면 됩니다.

잔디 격자에 진짜 1년치 데이터를 넣으려면 어디를 고치나요

시드 난수로 채우는 부분만 걷어내고 날짜별 개수를 담은 배열을 그 자리에 넣으면 됩니다. 칸의 인덱스는 주 번호 × 7 + 요일이고 마지막 칸이 오늘이므로, 서버에서 받은 배열도 오늘이 끝에 오도록 정렬해 주면 나머지 계산은 그대로 씁니다.

링과 막대 중 어느 쪽을 대시보드에 쓰는 게 낫나요

한눈에 다 채웠는지만 보이면 되는 자리는 링(05)이 낫고, 요일 사이의 차이를 비교해야 하면 막대(06)가 낫습니다. 링은 각도라 10%와 15%의 차이를 사람이 잘 못 읽고, 막대는 같은 축에 나란히 서 있어 길이 차이가 그대로 비교되기 때문입니다. 03만 떼어 체크리스트 ui 로 쓰는 길도 있는데, 습관 이름과 줄 수만 바꾸면 그대로 들어갑니다. 원형 게이지를 더 다루고 싶다면 원형 프로그레스바 위젯 9종이 그 축만 따로 파고듭니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.