습관 트래커 UI 9종 — 잔디 격자부터 공유 카드까지
습관 트래커는 오늘 한 일을 눌러 기록하고, 그 기록이 쌓인 모양을 한 화면에서 보여 주는 UI입니다. 여기 실은 아홉 개는 라이브러리 없이 바닐라 JS 와 SCSS 로만 짰고, 누르면 그 자리에서 값이 바뀌어 localStorage 에 남습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 1년 잔디 격자
- 02 연속 일수 불꽃
- 03 습관 7일 점 카드
- 04 달력 도장 찍기
- 05 삼중 목표 링
- 06 요일별 막대 요약
- 07 리마인더 시간 고르기
- 08 배지 잠금 해제
- 09 오늘 요약 공유 카드
아홉 개를 이 순서로 늘어놓은 기준은 기록이 사람에게 도착하는 거리입니다. 앞의 넷은 누른 흔적을 그대로 보여 주는 판 — 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까지 한 단계씩 오릅니다.
.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일이 되어도 화면을 덮지 않습니다.
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 에 넣습니다.
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으로 만들어 한 줄 띠로 묶이고, 줄이 바뀌면 이음을 끊습니다.
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] 를 링별로 따로 계산해야 하고, 하나로 쓰면 안쪽 링이 두 바퀴를 돌아 버립니다.
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: bottom 과 scaleY(var(--v)) 로만 키워 높이를 건드리지 않습니다.
.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시간으로 나옵니다.
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) 위에 「잠김」 글자를 함께 적어 색 없이도 읽힙니다.
.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 에 얹으므로 고해상도 화면에서도 글자가 뭉개지지 않습니다.
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종이 그 축만 따로 파고듭니다.