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. 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.
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-calendar-check" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 48v40H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M72 48v8a8 8 0 0 0 16 0v-8h80v8a8 8 0 0 0 16 0v-8h24v32H48V48Zm136 160H48V96h160zm-38.34-85.66a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L116 164.69l42.34-42.35a8 8 0 0 1 11.32 0"/></g></symbol>
<symbol id="ico-plus-circle" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M176 128a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8m56 0A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="cg is-demo" id="cg">
<p class="cg__hint">
<svg class="cg__ico" aria-hidden="true"><use href="#ico-calendar-check"/></svg>
<span>Point at a cell to see its date and count</span>
</p>
<div class="cg__wrap">
<span class="cg__tip" id="tip" role="status" aria-live="polite"></span>
<div class="cg__grid" id="grid" role="grid" aria-label="Habit log grid for the past year"></div>
</div>
<div class="cg__bar">
<p class="cg__legend">
<span class="cg__legendLabel">Per day</span>
<span class="cg__keyItem"><i class="cg__key cg__key--l0"></i><b class="cg__keyNum">0</b></span>
<span class="cg__keyItem"><i class="cg__key cg__key--l1"></i><b class="cg__keyNum">1</b></span>
<span class="cg__keyItem"><i class="cg__key cg__key--l2"></i><b class="cg__keyNum">2</b></span>
<span class="cg__keyItem"><i class="cg__key cg__key--l3"></i><b class="cg__keyNum">3</b></span>
<span class="cg__keyItem"><i class="cg__key cg__key--l4"></i><b class="cg__keyNum">4</b></span>
</p>
<button class="cg__add" id="add" type="button">
<svg class="cg__ico" aria-hidden="true"><use href="#ico-plus-circle"/></svg><span>Raise today</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 1년 잔디 격자 — 53주 × 7일. 칸은 JS 가 만들고 강도(0~4)는 시드 고정 난수,
// 오늘 칸만 localStorage 값으로 덮는다. 색 강도는 보조 신호일 뿐이라 범례에 숫자를 같이 적고
// 칸마다 aria-label 에 날짜와 횟수를 넣는다 (WCAG 1.4.1 — 색만으로 정보를 주지 않는다).
// 격자 미리보기(.is-demo)는 주 단위 animation-delay 로 왼쪽부터 차오르는 물결을 CSS 로 재생한다.
// 사람이 격자에 손을 대거나 버튼을 누르면 JS 가 .is-demo 를 떼고 정적 표시로 넘어간다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
.cg { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.cg__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-cream;
}
.cg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
// 말풍선은 절대배치라 부모 높이에 안 잡힌다 — padding-top 으로 자리를 미리 비워 둔다 (BD9)
.cg__wrap { position: relative; padding-top: $sp-5; }
.cg__tip {
position: absolute; left: 0; top: 0; height: $sp-4;
display: inline-flex; align-items: center; border-radius: $r-xs;
padding: 0 $sp-2; background: rgba($subject-cream, .12); color: $subject-cream;
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); white-space: nowrap;
}
.cg__grid { display: flex; flex-direction: column; }
.cg__row { display: flex; }
// 칸 사이 1px 씩은 gap 이 아니라 **투명 테두리**로 벌린다. 53주 × 7일을 424px 안에 넣으려면
// 칸 간격이 4px 격자에 못 맞는데(BD17), 테두리는 간격이 아니라 칸 자신의 일부라 그 규칙 밖이다.
// background-clip: padding-box 가 있어야 배경이 테두리 밑으로 새지 않는다.
.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__cell--l1 { background: rgba($color, .30); }
.cg__cell--l2 { background: rgba($color, .52); }
.cg__cell--l3 { background: rgba($color, .76); }
// 가장 높은 단계만 안쪽 테두리를 더해 색 말고 무늬로도 갈린다
.cg__cell--l4 { background: $color; box-shadow: inset 0 0 0 1px rgba($stage-ink, .55); }
.cg__cell.is-today { box-shadow: 0 0 0 2px rgba($subject-cream, .9); }
.cg__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.cg__legend { display: flex; align-items: center; gap: $sp-1; margin: 0; }
.cg__legendLabel {
margin-right: $sp-1; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px);
color: rgba($subject-cream, .72);
}
// 숫자를 색 칩 **안**에 넣으면 2단계(노랑 52%) 위에서 어느 글자색도 AA 를 못 넘는다 (실측 4.06·4.21:1).
// 칩은 칸과 같은 모양의 점으로만 두고 숫자는 무대 배경(잉크) 위에 따로 적는다 — 17.11:1.
.cg__keyItem { display: inline-flex; align-items: center; gap: $sp-1; }
.cg__key { width: 10px; height: 10px; border-radius: 50%; }
.cg__keyNum { font-weight: 800; font-size: 9px; line-height: 1; color: $subject-cream; }
.cg__key--l0 { background: rgba($subject-cream, .10); }
.cg__key--l1 { background: rgba($color, .30); }
.cg__key--l2 { background: rgba($color, .52); }
.cg__key--l3 { background: rgba($color, .76); }
.cg__key--l4 { background: $color; box-shadow: inset 0 0 0 1px rgba($stage-ink, .55); }
.cg__add {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $color; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.6;
}
.cg__add .cg__ico { color: $subject-ink; }
.cg__add:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 격자 미리보기 물결 ──────────────────────────────────────────
// 주(열)마다 12ms 씩 늦게 시작해 왼쪽부터 차오른다. 0% 와 100% 를 같은 값으로 둬서
// 루프 경계에 점프가 없고, 지연이 걸린 루프에는 fill-mode: backwards 가 반드시 붙는다
// (없으면 지연 구간에 작성 상태가 그대로 한 번 번쩍인다 — 252-08 실측).
.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; }
}
@media (max-width: 340px) {
.cg { gap: $sp-2; }
.cg__wrap { padding-top: $sp-4; }
.cg__cell { width: 5px; height: 5px; }
.cg__key { width: 8px; height: 8px; }
}
// reduce 에서는 물결을 끄고 잔디가 다 자란 한 장으로 세운다
@media (prefers-reduced-motion: reduce) {
.cg.is-demo .cg__cell { animation: none !important; transform: none !important; opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cg {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.cg__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #fff7e6;
}
.cg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ffd23f;
}
.cg__wrap {
position: relative;
padding-top: 20px;
}
.cg__tip {
position: absolute;
left: 0;
top: 0;
height: 16px;
display: inline-flex;
align-items: center;
border-radius: 4px;
padding: 0 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
white-space: nowrap;
}
.cg__grid {
display: flex;
flex-direction: column;
}
.cg__row {
display: flex;
}
.cg__cell {
box-sizing: border-box;
width: 7px;
height: 7px;
flex: 0 0 auto;
border: 1px solid transparent;
border-radius: 50%;
background: rgba(255, 247, 230, 0.1);
background-clip: padding-box;
}
.cg__cell--l1 {
background: rgba(255, 210, 63, 0.3);
}
.cg__cell--l2 {
background: rgba(255, 210, 63, 0.52);
}
.cg__cell--l3 {
background: rgba(255, 210, 63, 0.76);
}
.cg__cell--l4 {
background: #ffd23f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.55);
}
.cg__cell.is-today {
box-shadow: 0 0 0 2px rgba(255, 247, 230, 0.9);
}
.cg__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.cg__legend {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
}
.cg__legendLabel {
margin-right: 4px;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.72);
}
.cg__keyItem {
display: inline-flex;
align-items: center;
gap: 4px;
}
.cg__key {
width: 10px;
height: 10px;
border-radius: 50%;
}
.cg__keyNum {
font-weight: 800;
font-size: 9px;
line-height: 1;
color: #fff7e6;
}
.cg__key--l0 {
background: rgba(255, 247, 230, 0.1);
}
.cg__key--l1 {
background: rgba(255, 210, 63, 0.3);
}
.cg__key--l2 {
background: rgba(255, 210, 63, 0.52);
}
.cg__key--l3 {
background: rgba(255, 210, 63, 0.76);
}
.cg__key--l4 {
background: #ffd23f;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.55);
}
.cg__add {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #ffd23f;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.6;
}
.cg__add .cg__ico {
color: #17141a;
}
.cg__add:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cg.is-demo .cg__cell {
animation-name: cgWave;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--c) * 12ms);
}
@keyframes cgWave {
0% {
transform: scale(0.15);
opacity: 0.3;
}
14% {
transform: scale(1);
opacity: 1;
}
86% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.15);
opacity: 0.3;
}
}
@media (max-width: 340px) {
.cg {
gap: 8px;
}
.cg__wrap {
padding-top: 16px;
}
.cg__cell {
width: 5px;
height: 5px;
}
.cg__key {
width: 8px;
height: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cg.is-demo .cg__cell {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
}var WEEKS = 53, DAYS = 7, TOTAL = WEEKS * DAYS, STORE = 'godrich-habit-grid';
var root = document.getElementById('cg');
var grid = document.getElementById('grid');
var tip = document.getElementById('tip');
var TIP_TEXT = '{m}/{d} · {n} times';
var TODAY_TEXT = 'Today: {n}';
var CELL_LABEL = '{m}/{d} · {n} times';
var cells = [];
var levels = [];
// 가짜 기록은 시드 고정 난수 — 새로 고쳐도 같은 잔디가 나온다 (mulberry32)
function seeded(seed) {
return function () {
seed = seed + 0x6d2b79f5 | 0;
var t = Math.imul(seed ^ seed >>> 15, 1 | seed);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
function load() {
try { return Number(window.localStorage.getItem(STORE)) || 0; } catch (e) { return 0; }
}
function save(n) {
try { window.localStorage.setItem(STORE, String(n)); } catch (e) { /* 시크릿 창에서는 저장만 건너뛴다 */ }
}
function dateOf(index) {
var d = new Date();
d.setHours(12, 0, 0, 0);
d.setDate(d.getDate() - (TOTAL - 1 - index));
return d;
}
function label(index, level) {
var d = dateOf(index);
return CELL_LABEL.replace('{m}', String(d.getMonth() + 1)).replace('{d}', String(d.getDate())).replace('{n}', String(level));
}
function build() {
var rnd = seeded(20260912);
for (var i = 0; i < TOTAL; i++) {
var v = rnd();
levels.push(v > 0.82 ? 4 : v > 0.68 ? 3 : v > 0.48 ? 2 : v > 0.3 ? 1 : 0);
}
levels[TOTAL - 1] = load();
for (var r = 0; r < DAYS; r++) {
var row = document.createElement('div');
row.className = 'cg__row';
row.setAttribute('role', 'row');
for (var c = 0; c < WEEKS; c++) {
var index = c * DAYS + r;
var cell = document.createElement('span');
cell.className = 'cg__cell cg__cell--l' + levels[index];
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', label(index, levels[index]));
cell.style.setProperty('--c', String(c));
if (index === TOTAL - 1) { cell.classList.add('is-today'); }
cells[index] = cell;
row.appendChild(cell);
}
grid.appendChild(row);
}
}
function paintToday() {
var index = TOTAL - 1, cell = cells[index];
cell.className = 'cg__cell cg__cell--l' + levels[index] + ' is-today';
cell.setAttribute('aria-label', label(index, levels[index]));
tip.textContent = TODAY_TEXT.replace('{n}', String(levels[index]));
}
function show(cell) {
var index = cells.indexOf(cell);
if (index < 0) { return; }
var d = dateOf(index);
tip.textContent = TIP_TEXT.replace('{m}', String(d.getMonth() + 1)).replace('{d}', String(d.getDate())).replace('{n}', String(levels[index]));
}
build();
paintToday();
grid.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo');
if (e.target !== grid) { show(e.target); }
});
grid.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
if (e.target !== grid) { show(e.target); }
});
document.getElementById('add').addEventListener('click', function () {
root.classList.remove('is-demo');
var index = TOTAL - 1;
levels[index] = levels[index] >= 4 ? 0 : levels[index] + 1;
save(levels[index]);
paintToday();
});// ContributionGrid — vanilla 판(run/297/demo/01-habit-contribution-grid/index.html) 의 React 포트.
// 53주 × 7일 칸을 시드 고정 난수로 채우고 오늘 칸만 localStorage 값으로 덮는다. 값·수치는 vanilla 와 같다.
// 사람이 격자에 손을 대거나 버튼을 누르면 미리보기 루프를 떼고 정적 표시로 넘어간다.
import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ContributionGrid.module.scss";
export type ContributionGridProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CellVars = CSSProperties & { "--c"?: string };
const WEEKS = 53;
const DAYS = 7;
const TOTAL = WEEKS * DAYS;
const STORE = "godrich-habit-grid";
const HINT = "칸에 손을 올리면 날짜와 횟수가 보입니다";
const GRID_LABEL = "최근 1년 습관 기록 격자";
const LEGEND_LABEL = "하루 완료 수";
const ADD_TEXT = "오늘 칸 올리기";
const CELL_LABEL = "{m}월 {d}일 · {n}회";
const TODAY_TEXT = "오늘 {n}회";
// 가짜 기록은 시드 고정 난수 — 새로 고쳐도 같은 잔디가 나온다 (mulberry32)
function seeded(seed: number): () => number {
let s = seed;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function baseLevels(): number[] {
const rnd = seeded(20260912);
const out: number[] = [];
for (let i = 0; i < TOTAL; i++) {
const v = rnd();
out.push(v > 0.82 ? 4 : v > 0.68 ? 3 : v > 0.48 ? 2 : v > 0.3 ? 1 : 0);
}
return out;
}
function dateOf(index: number): Date {
const d = new Date();
d.setHours(12, 0, 0, 0);
d.setDate(d.getDate() - (TOTAL - 1 - index));
return d;
}
function label(index: number, level: number): string {
const d = dateOf(index);
return CELL_LABEL.replace("{m}", String(d.getMonth() + 1))
.replace("{d}", String(d.getDate()))
.replace("{n}", String(level));
}
export default function ContributionGrid({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffd23f",
}: ContributionGridProps) {
const base = useMemo(baseLevels, []);
const [today, setToday] = useState(0);
const [tip, setTip] = useState("");
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setToday(Number(raw) || 0); }
} catch (e) { /* 시크릿 창에서는 기본값 그대로 간다 */ }
}, []);
function levelAt(index: number): number {
return index === TOTAL - 1 ? today : base[index];
}
function save(n: number) {
try { window.localStorage.setItem(STORE, String(n)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function raise() {
setDemo(false);
const next = today >= 4 ? 0 : today + 1;
setToday(next);
save(next);
setTip(TODAY_TEXT.replace("{n}", String(next)));
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
setDemo(false);
const el = event.target as HTMLElement;
const raw = el.getAttribute("data-index");
if (raw === null) { return; }
const index = Number(raw);
setTip(label(index, levelAt(index)));
}
const levelClass = [styles.cell, styles.l1, styles.l2, styles.l3, styles.l4];
const keyClass = [styles.key0, styles.key1, styles.key2, styles.key3, styles.key4];
const rows: number[] = [0, 1, 2, 3, 4, 5, 6];
const weeks: number[] = [];
for (let c = 0; c < WEEKS; c++) { weeks.push(c); }
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const cellVars = (c: number): CellVars => ({ "--c": String(c) });
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-calendar-check" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 48v40H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M72 48v8a8 8 0 0 0 16 0v-8h80v8a8 8 0 0 0 16 0v-8h24v32H48V48Zm136 160H48V96h160zm-38.34-85.66a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L116 164.69l42.34-42.35a8 8 0 0 1 11.32 0"/></g></symbol>
<symbol id="ico-plus-circle" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M176 128a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8m56 0A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-calendar-check" /></svg>
<span>{HINT}</span>
</p>
<div className={styles.wrap}>
<span className={styles.tip} role="status" aria-live="polite">{tip}</span>
<div className={styles.grid} role="grid" aria-label={GRID_LABEL} onPointerMove={onPointerMove}>
{rows.map((r) => (
<div className={styles.row} role="row" key={r}>
{weeks.map((c) => {
const index = c * DAYS + r;
const level = levelAt(index);
const isToday = index === TOTAL - 1;
const cn = [levelClass[0], level > 0 ? levelClass[level] : "", isToday ? styles.today : ""]
.filter(Boolean).join(" ");
return (
<span
className={cn}
key={c}
role="gridcell"
data-index={index}
aria-label={label(index, level)}
style={cellVars(c)}
/>
);
})}
</div>
))}
</div>
</div>
<div className={styles.bar}>
<p className={styles.legend}>
<span className={styles.legendLabel}>{LEGEND_LABEL}</span>
{[0, 1, 2, 3, 4].map((n) => (
<span className={styles.keyItem} key={n}>
<i className={`${styles.key} ${keyClass[n]}`} />
<b className={styles.keyNum}>{n}</b>
</span>
))}
</p>
<button className={styles.add} type="button" onClick={raise}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-plus-circle" /></svg>
<span>{ADD_TEXT}</span>
</button>
</div>
</div>
</main>
);
}01 · Year contribution grid — Measured area 9.524% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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.
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-fire" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 144a80 80 0 0 1-160 0c0-30.57 14.42-58.26 31-80l33 32l26.27-72C159.86 41.92 208 88.15 208 144" opacity=".2"/><path d="M183.89 153.34a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68ZM216 144a88 88 0 0 1-176 0c0-27.92 11-56.47 32.66-84.85a8 8 0 0 1 11.93-.89l24.12 23.41l22-60.41a8 8 0 0 1 12.63-3.41C165.21 36 216 84.55 216 144m-16 0c0-46.09-35.79-85.92-58.21-106.33l-22.27 61.07a8 8 0 0 1-13.09 3L80.06 76.16C64.09 99.21 56 122 56 144a72 72 0 0 0 144 0"/></g></symbol>
<symbol id="ico-check-circle" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="sc is-demo" id="sc">
<div class="sc__top">
<span class="sc__flameBox">
<span class="sc__halo" aria-hidden="true"></span>
<svg class="sc__flame" id="flame" aria-hidden="true"><use href="#ico-fire"/></svg>
</span>
<p class="sc__read">
<b class="sc__num" id="num">12</b>
<span class="sc__unit">day streak</span>
<span class="sc__best" id="best">Best 21</span>
</p>
</div>
<p class="sc__week" aria-hidden="true">
<i class="sc__dot" style="--i:0"></i><i class="sc__dot" style="--i:1"></i><i class="sc__dot" style="--i:2"></i>
<i class="sc__dot" style="--i:3"></i><i class="sc__dot" style="--i:4"></i><i class="sc__dot" style="--i:5"></i>
<i class="sc__dot" style="--i:6"></i>
</p>
<p class="sc__say" id="say" role="status" aria-live="polite">12 days in a row</p>
<div class="sc__bar">
<button class="sc__go" id="go" type="button">
<svg class="sc__ico" aria-hidden="true"><use href="#ico-check-circle"/></svg><span>Check today</span>
</button>
<button class="sc__cut" id="cut" type="button">Break streak</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 연속 일수 불꽃 — 불꽃은 연속 일수를 그대로 키우지 않고 네 단계(--s1~--s4)로만 커진다.
// 커지는 주체가 스스로 완결한 아이콘이므로 transform-origin 은 자기 중심에 둔다 (blog-demo 규칙 8).
// 미리보기(.is-demo)는 세 갈래를 겹친다 — 주간 점이 차례로 차고, 불꽃이 steps(1, end) 로 한 단계씩 뛰고,
// 후광이 한 번 퍼진다. 78% 에서 연속이 끊기는 장면을 컷으로 넣고 88% 안에 처음 상태로 돌아온다.
// 사람이 버튼을 누르면 JS 가 .is-demo 를 떼고 숫자·최장 기록·aria-live 문장을 실제 상태로 바꾼다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.sc { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.sc__top { display: flex; align-items: center; gap: $sp-4; }
.sc__flameBox {
position: relative; display: inline-grid; place-items: center;
width: 72px; height: 72px; flex: 0 0 auto;
}
.sc__halo {
position: absolute; width: 72px; height: 72px; border-radius: 50%;
border: 2px solid rgba($color, .75); opacity: 0;
}
// 불꽃은 스스로 완결한 아이콘이라 커질 때 제 중심에서 커진다 (모서리 origin 이면 옆으로 밀리며 늘어난다)
.sc__flame { width: 48px; height: 48px; color: $color; transform-origin: 50% 50%; }
.sc__flame--s1 { width: 30px; height: 30px; }
.sc__flame--s2 { width: 38px; height: 38px; }
.sc__flame--s3 { width: 48px; height: 48px; }
.sc__flame--s4 { width: 56px; height: 56px; }
.sc__flame--s0 { color: rgba($color, .38); }
.sc__read { display: flex; align-items: baseline; flex-wrap: wrap; gap: $sp-2; margin: 0; }
.sc__num { font-weight: 800; font-size: clamp(34px, 11vw, 48px); line-height: 1; color: $color; }
.sc__unit { font-weight: 800; font-size: clamp(13px, 3.6vw, 16px); color: $stage-ink; }
.sc__best {
border-radius: $r-pill; padding: $sp-1 $sp-2; background: rgba($stage-ink, .28);
font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.2; color: $color;
}
.sc__week { display: flex; gap: $sp-2; margin: 0; }
.sc__dot { width: 14px; height: 14px; border-radius: 50%; background: $color; }
.sc__say {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $stage-ink;
}
.sc__bar { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; }
.sc__ico { width: 16px; height: 16px; flex: 0 0 auto; }
.sc__go, .sc__cut {
display: inline-flex; align-items: center; gap: $sp-1; border-radius: $r-pill;
padding: $sp-1 $sp-3; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.6;
}
.sc__go { border: 0; background: $color; color: $stage-ink; }
.sc__cut { border: 2px solid rgba($stage-ink, .45); background: transparent; color: $stage-ink; }
.sc__go:focus-visible, .sc__cut:focus-visible { outline: 2px solid $stage-ink; outline-offset: 2px; }
// ── 격자 미리보기 ───────────────────────────────────────────────
.sc.is-demo .sc__dot {
animation-name: scDot; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 70ms);
}
@keyframes scDot {
0% { transform: scale(.35); opacity: .35; }
9% { transform: scale(1); opacity: 1; }
70% { transform: scale(1); opacity: 1; }
78% { transform: scale(.35); opacity: .35; }
100% { transform: scale(.35); opacity: .35; }
}
// 불꽃은 단계가 있는 값이라 중간값 없이 컷으로 뛴다 — 반투명 겹침이 생기지 않는다
.sc.is-demo .sc__flame {
animation-name: scFlame; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes scFlame {
0% { transform: scale(.62); opacity: 1; }
26% { transform: scale(.8); opacity: 1; }
50% { transform: scale(1); opacity: 1; }
70% { transform: scale(1.16); opacity: 1; }
78% { transform: scale(.62); opacity: .4; }
88% { transform: scale(.62); opacity: 1; }
100% { transform: scale(.62); opacity: 1; }
}
.sc.is-demo .sc__halo {
animation-name: scHalo; animation-duration: $duration; animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes scHalo {
0% { transform: scale(.5); opacity: 0; }
30% { transform: scale(.78); opacity: .7; }
70% { transform: scale(1.1); opacity: .18; }
88% { transform: scale(.5); opacity: 0; }
100% { transform: scale(.5); opacity: 0; }
}
@media (max-width: 340px) {
.sc { gap: $sp-2; }
.sc__flameBox { width: 52px; height: 52px; }
.sc__halo { width: 52px; height: 52px; }
.sc__flame, .sc__flame--s3 { width: 32px; height: 32px; }
.sc__dot { width: 10px; height: 10px; }
.sc__week { gap: $sp-1; }
.sc__top { gap: $sp-2; }
}
// reduce 에서는 불꽃·후광·점을 멈추고 지금 상태 한 장만 남긴다
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__dot,
.sc.is-demo .sc__flame,
.sc.is-demo .sc__halo { animation: none !important; }
.sc.is-demo .sc__dot { transform: none !important; opacity: 1 !important; }
.sc.is-demo .sc__flame { transform: none !important; opacity: 1 !important; }
.sc.is-demo .sc__halo { opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.sc__top {
display: flex;
align-items: center;
gap: 16px;
}
.sc__flameBox {
position: relative;
display: inline-grid;
place-items: center;
width: 72px;
height: 72px;
flex: 0 0 auto;
}
.sc__halo {
position: absolute;
width: 72px;
height: 72px;
border-radius: 50%;
border: 2px solid rgba(255, 247, 230, 0.75);
opacity: 0;
}
.sc__flame {
width: 48px;
height: 48px;
color: #fff7e6;
transform-origin: 50% 50%;
}
.sc__flame--s1 {
width: 30px;
height: 30px;
}
.sc__flame--s2 {
width: 38px;
height: 38px;
}
.sc__flame--s3 {
width: 48px;
height: 48px;
}
.sc__flame--s4 {
width: 56px;
height: 56px;
}
.sc__flame--s0 {
color: rgba(255, 247, 230, 0.38);
}
.sc__read {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
margin: 0;
}
.sc__num {
font-weight: 800;
font-size: clamp(34px, 11vw, 48px);
line-height: 1;
color: #fff7e6;
}
.sc__unit {
font-weight: 800;
font-size: clamp(13px, 3.6vw, 16px);
color: #17141a;
}
.sc__best {
border-radius: 999px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.28);
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.2;
color: #fff7e6;
}
.sc__week {
display: flex;
gap: 8px;
margin: 0;
}
.sc__dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff7e6;
}
.sc__say {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.sc__bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.sc__go, .sc__cut {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
padding: 4px 12px;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.6;
}
.sc__go {
border: 0;
background: #fff7e6;
color: #17141a;
}
.sc__cut {
border: 2px solid rgba(23, 20, 26, 0.45);
background: transparent;
color: #17141a;
}
.sc__go:focus-visible, .sc__cut:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sc.is-demo .sc__dot {
animation-name: scDot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 70ms);
}
@keyframes scDot {
0% {
transform: scale(0.35);
opacity: 0.35;
}
9% {
transform: scale(1);
opacity: 1;
}
70% {
transform: scale(1);
opacity: 1;
}
78% {
transform: scale(0.35);
opacity: 0.35;
}
100% {
transform: scale(0.35);
opacity: 0.35;
}
}
.sc.is-demo .sc__flame {
animation-name: scFlame;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes scFlame {
0% {
transform: scale(0.62);
opacity: 1;
}
26% {
transform: scale(0.8);
opacity: 1;
}
50% {
transform: scale(1);
opacity: 1;
}
70% {
transform: scale(1.16);
opacity: 1;
}
78% {
transform: scale(0.62);
opacity: 0.4;
}
88% {
transform: scale(0.62);
opacity: 1;
}
100% {
transform: scale(0.62);
opacity: 1;
}
}
.sc.is-demo .sc__halo {
animation-name: scHalo;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes scHalo {
0% {
transform: scale(0.5);
opacity: 0;
}
30% {
transform: scale(0.78);
opacity: 0.7;
}
70% {
transform: scale(1.1);
opacity: 0.18;
}
88% {
transform: scale(0.5);
opacity: 0;
}
100% {
transform: scale(0.5);
opacity: 0;
}
}
@media (max-width: 340px) {
.sc {
gap: 8px;
}
.sc__flameBox {
width: 52px;
height: 52px;
}
.sc__halo {
width: 52px;
height: 52px;
}
.sc__flame, .sc__flame--s3 {
width: 32px;
height: 32px;
}
.sc__dot {
width: 10px;
height: 10px;
}
.sc__week {
gap: 4px;
}
.sc__top {
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__dot,
.sc.is-demo .sc__flame,
.sc.is-demo .sc__halo {
animation: none !important;
}
.sc.is-demo .sc__dot {
transform: none !important;
opacity: 1 !important;
}
.sc.is-demo .sc__flame {
transform: none !important;
opacity: 1 !important;
}
.sc.is-demo .sc__halo {
opacity: 0 !important;
}
}var STORE = 'godrich-habit-streak';
var root = document.getElementById('sc');
var flame = document.getElementById('flame');
var numEl = document.getElementById('num');
var bestEl = document.getElementById('best');
var sayEl = document.getElementById('say');
var SAY_ON = '{n} days in a row';
var SAY_OFF = 'Streak broken. Your best is {b} days';
var BEST_TEXT = 'Best {b}';
var state = { streak: 12, best: 21 };
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { state = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본값 그대로 간다 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(state)); } catch (e) { /* 저장만 건너뛴다 */ }
}
// 불꽃 크기는 네 단계뿐이다 — 연속 일수를 그대로 scale 에 넣으면 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));
// SVG 요소의 className 은 문자열이 아니라 SVGAnimatedString 이다 — 대입해도 클래스가 안 바뀐다.
// 조작 실측에서 불꽃 단계가 영원히 그대로였던 원인이 이것이었다. setAttribute 로 넣는다.
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));
}
load();
paint(false);
document.getElementById('go').addEventListener('click', function () {
root.classList.remove('is-demo');
state.streak = state.streak + 1;
if (state.streak > state.best) { state.best = state.streak; }
save();
paint(false);
});
document.getElementById('cut').addEventListener('click', function () {
root.classList.remove('is-demo');
state.streak = 0;
save();
paint(true);
});// StreakFlame — vanilla 판(run/297/demo/02-habit-streak-counter/index.html) 의 React 포트.
// 불꽃 크기는 연속 일수를 그대로 쓰지 않고 네 단계로만 커진다. 값·수치는 vanilla 와 같다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StreakFlame.module.scss";
export type StreakFlameProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DotVars = CSSProperties & { "--i"?: string };
type State = { streak: number; best: number };
const STORE = "godrich-habit-streak";
const UNIT_TEXT = "일 연속";
const BEST_TEXT = "최장 {b}일";
const SAY_ON = "{n}일 연속입니다";
const SAY_OFF = "연속이 끊겼습니다. 최장 기록은 {b}일";
const GO_TEXT = "오늘 체크";
const CUT_TEXT = "연속 끊기";
// 불꽃 크기는 네 단계뿐이다 — 연속 일수를 그대로 scale 에 넣으면 100일에 화면을 덮는다
function stepOf(n: number): number {
return n >= 30 ? 4 : n >= 14 ? 3 : n >= 3 ? 2 : n >= 1 ? 1 : 0;
}
export default function StreakFlame({
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#fff7e6",
}: StreakFlameProps) {
const [state, setState] = useState<State>({ streak: 12, best: 21 });
const [broken, setBroken] = useState(false);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setState(JSON.parse(raw) as State); }
} catch (e) { /* 저장소가 막힌 창에서는 기본값 그대로 간다 */ }
}, []);
function save(next: State) {
try { window.localStorage.setItem(STORE, JSON.stringify(next)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function check() {
setDemo(false);
const streak = state.streak + 1;
const next: State = { streak: streak, best: Math.max(streak, state.best) };
setState(next);
setBroken(false);
save(next);
}
function cut() {
setDemo(false);
const next: State = { streak: 0, best: state.best };
setState(next);
setBroken(true);
save(next);
}
const sizeClass = [styles.s0, styles.s1, styles.s2, styles.s3, styles.s4];
const say = broken
? SAY_OFF.replace("{b}", String(state.best))
: SAY_ON.replace("{n}", String(state.streak));
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const dotVars = (i: number): DotVars => ({ "--i": String(i) });
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-fire" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 144a80 80 0 0 1-160 0c0-30.57 14.42-58.26 31-80l33 32l26.27-72C159.86 41.92 208 88.15 208 144" opacity=".2"/><path d="M183.89 153.34a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68ZM216 144a88 88 0 0 1-176 0c0-27.92 11-56.47 32.66-84.85a8 8 0 0 1 11.93-.89l24.12 23.41l22-60.41a8 8 0 0 1 12.63-3.41C165.21 36 216 84.55 216 144m-16 0c0-46.09-35.79-85.92-58.21-106.33l-22.27 61.07a8 8 0 0 1-13.09 3L80.06 76.16C64.09 99.21 56 122 56 144a72 72 0 0 0 144 0"/></g></symbol>
<symbol id="ico-check-circle" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<div className={styles.top}>
<span className={styles.flameBox}>
<span className={styles.halo} aria-hidden="true" />
<svg className={`${styles.flame} ${sizeClass[stepOf(state.streak)]}`} aria-hidden="true">
<use href="#ico-fire" />
</svg>
</span>
<p className={styles.read}>
<b className={styles.num}>{state.streak}</b>
<span className={styles.unit}>{UNIT_TEXT}</span>
<span className={styles.best}>{BEST_TEXT.replace("{b}", String(state.best))}</span>
</p>
</div>
<p className={styles.week} aria-hidden="true">
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<i className={styles.dot} key={i} style={dotVars(i)} />
))}
</p>
<p className={styles.say} role="status" aria-live="polite">{say}</p>
<div className={styles.bar}>
<button className={styles.go} type="button" onClick={check}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-check-circle" /></svg>
<span>{GO_TEXT}</span>
</button>
<button className={styles.cut} type="button" onClick={cut}>{CUT_TEXT}</button>
</div>
</div>
</main>
);
}02 · Streak flame counter — Measured area 4.381% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
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.
<main class="stage">
<div class="cw is-demo" id="cw">
<div class="cw__head">
<p class="cw__title">This week</p>
<p class="cw__say" id="say" role="status" aria-live="polite">13 dots filled this week</p>
</div>
<div class="cw__main">
<div class="cw__rows">
<div class="cw__row">
<span class="cw__name">Water 2L</span>
<div class="cw__days" id="row0"></div>
</div>
<div class="cw__row">
<span class="cw__name">Stretching</span>
<div class="cw__days" id="row1"></div>
</div>
<div class="cw__row">
<span class="cw__name">Read 20p</span>
<div class="cw__days" id="row2"></div>
</div>
</div>
<div class="cw__ringBox">
<svg class="cw__ring" viewBox="0 0 64 64" aria-hidden="true">
<circle class="cw__track" cx="32" cy="32" r="26"/>
<circle class="cw__fill" id="ring" cx="32" cy="32" r="26"/>
</svg>
<b class="cw__pct" id="pct">62%</b>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 습관 7일 점 카드 — 세 줄 × 7일 점. 점은 진짜 <button> 이라 aria-pressed 로 눌린 상태가 그대로 읽히고,
// 오른쪽 링은 채운 칸 수 ÷ 전체 칸 수를 stroke-dashoffset 으로 감는다 (둘레는 JS 가 2πr 로 계산해 넣는다).
// 미리보기(.is-demo)는 21개 점을 차례로 채우고 링을 0 에서 목표까지 감는다.
// 애니메이션 선언은 인라인 style 보다 위 단계라, JS 가 넣은 offset 값을 미리보기가 덮는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.cw { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.cw__head { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.cw__title { margin: 0; font-weight: 800; font-size: clamp(13px, 3.4vw, 15px); color: $subject-ink; }
.cw__say { margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .62); }
.cw__main { display: flex; align-items: center; gap: $sp-4; }
.cw__rows { flex: 1 1 auto; display: flex; flex-direction: column; gap: $sp-2; min-width: 0; }
.cw__row { display: grid; grid-template-columns: 62px 1fr; align-items: center; gap: $sp-2; }
.cw__name {
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: $subject-ink;
overflow: hidden; text-overflow: clip; white-space: nowrap;
}
.cw__days { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1; }
.cw__dot {
width: 100%; max-width: 22px; aspect-ratio: 1; border-radius: 50%; padding: 0; cursor: pointer;
border: 2px solid rgba($subject-ink, .22); background: transparent;
}
.cw__dot[aria-pressed="true"] { border-color: $color; background: $color; }
.cw__dot:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cw__ringBox { position: relative; display: grid; place-items: center; width: 88px; height: 88px; flex: 0 0 auto; }
.cw__ring { width: 88px; height: 88px; transform: rotate(-90deg); }
.cw__track { fill: none; stroke: rgba($subject-ink, .14); stroke-width: 8; }
.cw__fill {
fill: none; stroke: $color; stroke-width: 8; stroke-linecap: round;
stroke-dasharray: 163.36; stroke-dashoffset: 54;
}
.cw__pct {
position: absolute; font-weight: 800; font-size: clamp(13px, 3.4vw, 15px); color: $subject-ink;
}
// ── 격자 미리보기 ───────────────────────────────────────────────
.cw.is-demo .cw__dot {
animation-name: cwDot; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 24ms);
}
@keyframes cwDot {
0% { transform: scale(.4); border-color: rgba($subject-ink, .22); background-color: transparent; }
10% { transform: scale(1); border-color: $color; background-color: $color; }
74% { transform: scale(1); border-color: $color; background-color: $color; }
84% { transform: scale(.4); border-color: rgba($subject-ink, .22); background-color: transparent; }
100% { transform: scale(.4); border-color: rgba($subject-ink, .22); background-color: transparent; }
}
.cw.is-demo .cw__fill {
animation-name: cwRing; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cwRing {
0% { stroke-dashoffset: 163.36; }
62% { stroke-dashoffset: 54; }
84% { stroke-dashoffset: 163.36; }
100% { stroke-dashoffset: 163.36; }
}
@media (max-width: 340px) {
.cw { gap: $sp-2; }
.cw__main { gap: $sp-2; }
.cw__row { grid-template-columns: 48px 1fr; gap: $sp-1; }
.cw__dot { max-width: 14px; border-width: 2px; }
.cw__ringBox { width: 56px; height: 56px; }
.cw__ring { width: 56px; height: 56px; }
}
// reduce 에서는 점과 링을 멈추고 지금 기록 한 장만 보여 준다
@media (prefers-reduced-motion: reduce) {
.cw.is-demo .cw__dot { animation: none !important; transform: none !important; }
.cw.is-demo .cw__fill { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cw {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.cw__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.cw__title {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 15px);
color: #17141a;
}
.cw__say {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.62);
}
.cw__main {
display: flex;
align-items: center;
gap: 16px;
}
.cw__rows {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.cw__row {
display: grid;
grid-template-columns: 62px 1fr;
align-items: center;
gap: 8px;
}
.cw__name {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: #17141a;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.cw__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.cw__dot {
width: 100%;
max-width: 22px;
aspect-ratio: 1;
border-radius: 50%;
padding: 0;
cursor: pointer;
border: 2px solid rgba(23, 20, 26, 0.22);
background: transparent;
}
.cw__dot[aria-pressed=true] {
border-color: #2f6df6;
background: #2f6df6;
}
.cw__dot:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cw__ringBox {
position: relative;
display: grid;
place-items: center;
width: 88px;
height: 88px;
flex: 0 0 auto;
}
.cw__ring {
width: 88px;
height: 88px;
transform: rotate(-90deg);
}
.cw__track {
fill: none;
stroke: rgba(23, 20, 26, 0.14);
stroke-width: 8;
}
.cw__fill {
fill: none;
stroke: #2f6df6;
stroke-width: 8;
stroke-linecap: round;
stroke-dasharray: 163.36;
stroke-dashoffset: 54;
}
.cw__pct {
position: absolute;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 15px);
color: #17141a;
}
.cw.is-demo .cw__dot {
animation-name: cwDot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 24ms);
}
@keyframes cwDot {
0% {
transform: scale(0.4);
border-color: rgba(23, 20, 26, 0.22);
background-color: transparent;
}
10% {
transform: scale(1);
border-color: #2f6df6;
background-color: #2f6df6;
}
74% {
transform: scale(1);
border-color: #2f6df6;
background-color: #2f6df6;
}
84% {
transform: scale(0.4);
border-color: rgba(23, 20, 26, 0.22);
background-color: transparent;
}
100% {
transform: scale(0.4);
border-color: rgba(23, 20, 26, 0.22);
background-color: transparent;
}
}
.cw.is-demo .cw__fill {
animation-name: cwRing;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes cwRing {
0% {
stroke-dashoffset: 163.36;
}
62% {
stroke-dashoffset: 54;
}
84% {
stroke-dashoffset: 163.36;
}
100% {
stroke-dashoffset: 163.36;
}
}
@media (max-width: 340px) {
.cw {
gap: 8px;
}
.cw__main {
gap: 8px;
}
.cw__row {
grid-template-columns: 48px 1fr;
gap: 4px;
}
.cw__dot {
max-width: 14px;
border-width: 2px;
}
.cw__ringBox {
width: 56px;
height: 56px;
}
.cw__ring {
width: 56px;
height: 56px;
}
}
@media (prefers-reduced-motion: reduce) {
.cw.is-demo .cw__dot {
animation: none !important;
transform: none !important;
}
.cw.is-demo .cw__fill {
animation: none !important;
}
}var STORE = 'godrich-habit-week';
var DAYS = 7;
var DOT_LABEL = '{h}, day {d}';
var SAY_TEXT = '{n} dots filled this week';
var CIRC = 2 * Math.PI * 26; // r=26 원의 둘레. stroke-dasharray 와 offset 이 이 값을 나눠 쓴다
var root = document.getElementById('cw');
var ring = document.getElementById('ring');
var pctEl = document.getElementById('pct');
var sayEl = document.getElementById('say');
// 습관 이름은 화면에 이미 있으므로 DOM 에서 읽는다 — 같은 글자를 JS 에 한 번 더 적지 않는다
var names = Array.prototype.map.call(document.querySelectorAll('.cw__name'), function (el) { return el.textContent; });
var marks = [
[1, 1, 1, 0, 1, 1, 0],
[1, 1, 0, 1, 1, 0, 0],
[1, 0, 1, 1, 0, 1, 0]
];
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { marks = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(marks)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function total() {
var n = 0;
for (var r = 0; r < marks.length; r++) {
for (var d = 0; d < DAYS; d++) { n += marks[r][d]; }
}
return n;
}
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));
}
function build() {
for (var r = 0; r < marks.length; r++) {
var box = document.getElementById('row' + r);
for (var d = 0; d < DAYS; d++) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'cw__dot';
b.style.setProperty('--i', String(r * DAYS + d));
b.setAttribute('aria-pressed', marks[r][d] ? 'true' : 'false');
b.setAttribute('aria-label', DOT_LABEL.replace('{h}', names[r]).replace('{d}', String(d + 1)));
b.setAttribute('data-r', String(r));
b.setAttribute('data-d', String(d));
box.appendChild(b);
}
}
}
load();
build();
paint();
document.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('.cw__dot') : null;
if (!b) { return; }
root.classList.remove('is-demo');
var r = Number(b.getAttribute('data-r')), d = Number(b.getAttribute('data-d'));
marks[r][d] = marks[r][d] ? 0 : 1;
b.setAttribute('aria-pressed', marks[r][d] ? 'true' : 'false');
save();
paint();
});// WeekDotCard — vanilla 판(run/297/demo/03-habit-checklist-week/index.html) 의 React 포트.
// 점은 진짜 <button> 이라 aria-pressed 로 눌린 상태가 그대로 읽히고, 링은 채운 칸 비율만큼 감긴다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WeekDotCard.module.scss";
export type WeekDotCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DotVars = CSSProperties & { "--i"?: string };
const STORE = "godrich-habit-week";
const DAYS = 7;
const TITLE = "이번 주 습관";
const NAMES = ["물 2L", "스트레칭", "책 20쪽"];
const DOT_LABEL = "{h} {d}일째";
const SAY_TEXT = "이번 주 {n}칸 채웠습니다";
const CIRC = 2 * Math.PI * 26; // r=26 원의 둘레. stroke-dasharray 와 offset 이 이 값을 나눠 쓴다
const START: number[][] = [
[1, 1, 1, 0, 1, 1, 0],
[1, 1, 0, 1, 1, 0, 0],
[1, 0, 1, 1, 0, 1, 0],
];
export default function WeekDotCard({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: WeekDotCardProps) {
const [marks, setMarks] = useState<number[][]>(START);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setMarks(JSON.parse(raw) as number[][]); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
}, []);
function save(next: number[][]) {
try { window.localStorage.setItem(STORE, JSON.stringify(next)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function toggle(r: number, d: number) {
setDemo(false);
const next = marks.map((row, ri) => row.map((v, di) => (ri === r && di === d ? (v ? 0 : 1) : v)));
setMarks(next);
save(next);
}
let filled = 0;
for (let r = 0; r < marks.length; r++) {
for (let d = 0; d < DAYS; d++) { filled += marks[r][d]; }
}
const ratio = filled / (marks.length * DAYS);
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const dotVars = (i: number): DotVars => ({ "--i": String(i) });
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.head}>
<p className={styles.title}>{TITLE}</p>
<p className={styles.say} role="status" aria-live="polite">
{SAY_TEXT.replace("{n}", String(filled))}
</p>
</div>
<div className={styles.main}>
<div className={styles.rows}>
{marks.map((row, r) => (
<div className={styles.row} key={r}>
<span className={styles.name}>{NAMES[r]}</span>
<div className={styles.days}>
{row.map((v, d) => (
<button
className={styles.dot}
key={d}
type="button"
aria-pressed={v ? "true" : "false"}
aria-label={DOT_LABEL.replace("{h}", NAMES[r]).replace("{d}", String(d + 1))}
style={dotVars(r * DAYS + d)}
onClick={() => toggle(r, d)}
/>
))}
</div>
</div>
))}
</div>
<div className={styles.ringBox}>
<svg className={styles.ring} viewBox="0 0 64 64" aria-hidden="true">
<circle className={styles.track} cx="32" cy="32" r="26" />
<circle
className={styles.fill}
cx="32"
cy="32"
r="26"
style={{ strokeDasharray: String(CIRC), strokeDashoffset: String(CIRC * (1 - ratio)) }}
/>
</svg>
<b className={styles.pct}>{Math.round(ratio * 100)}%</b>
</div>
</div>
</div>
</main>
);
}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
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.
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-check-circle" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></symbol>
<symbol id="ico-calendar-check" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 48v40H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M72 48v8a8 8 0 0 0 16 0v-8h80v8a8 8 0 0 0 16 0v-8h24v32H48V48Zm136 160H48V96h160zm-38.34-85.66a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L116 164.69l42.34-42.35a8 8 0 0 1 11.32 0"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="cs is-demo" id="cs">
<div class="cs__head">
<p class="cs__title">
<svg class="cs__ico" aria-hidden="true"><use href="#ico-calendar-check"/></svg>
<span>September stamps</span>
</p>
<p class="cs__say" id="say" role="status" aria-live="polite">16 stamps</p>
</div>
<div class="cs__days" id="days" role="grid" aria-label="September habit stamp calendar"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 달력 도장 찍기 — 한 달 칸을 role="grid" · role="row" · role="gridcell" 로 짜고 칸마다 aria-pressed 를 둔다.
// 도장은 rotate 와 scale 로 비스듬히 내려앉고, 이어진 날은 칸 배경의 모서리 반경을 없애 한 줄 띠가 된다
// (줄이 바뀌면 이음을 끊는다 — 달력은 줄 단위로 읽히기 때문).
// 미리보기(.is-demo)는 날짜 순으로 도장이 찍히는 장면을 재생하고 사람이 칸을 누르면 진짜 토글로 넘어간다.
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-yellow);
.cs { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.cs__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.cs__title {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(12px, 3.2vw, 14px); color: $color;
}
.cs__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.cs__say {
margin: 0; border-radius: $r-pill; padding: $sp-1 $sp-2; background: rgba($color, .14);
font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.2; color: $color;
}
.cs__days { display: flex; flex-direction: column; gap: $sp-1; }
// 칸 사이 가로 간격을 0 으로 둬야 이어진 날의 띠가 끊기지 않는다
.cs__row { display: grid; grid-template-columns: repeat(7, 1fr); }
.cs__cell {
position: relative; width: 100%; height: 30px; display: grid; place-items: center;
border: 0; border-radius: $r-sm; padding: 0; background: transparent; cursor: pointer;
font-family: inherit; color: rgba($color, .78);
}
.cs__n { font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1; }
.cs__cell.is-on { background: rgba($color, .16); color: $color; }
.cs__cell.is-on .cs__n { font-weight: 800; }
.cs__cell.is-left { border-top-left-radius: 0; border-bottom-left-radius: 0; }
.cs__cell.is-right { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.cs__cell:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
// 도장은 날짜 글자를 가리지 않게 칸 오른쪽 위에 앉는다 — 날짜는 늘 읽혀야 한다
.cs__stamp {
position: absolute; right: 2px; top: 2px; width: 14px; height: 14px; color: $color;
opacity: 0; transform: rotate(-12deg) scale(.5);
}
.cs__cell.is-on .cs__stamp { opacity: .95; transform: rotate(-12deg) scale(1); }
// ── 격자 미리보기 ───────────────────────────────────────────────
// 날짜 순으로 도장이 내려앉는다. 지연이 걸린 루프라 fill-mode: backwards 가 필수다.
.cs.is-demo .cs__cell .cs__stamp {
animation-name: csStamp; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 18ms);
}
@keyframes csStamp {
0% { opacity: 0; transform: rotate(-42deg) scale(.3); }
12% { opacity: .95; transform: rotate(-12deg) scale(1); }
76% { opacity: .95; transform: rotate(-12deg) scale(1); }
86% { opacity: 0; transform: rotate(-42deg) scale(.3); }
100% { opacity: 0; transform: rotate(-42deg) scale(.3); }
}
// 칸 배경 띠도 도장과 같은 박자로 들어온다 — 도장만 뜨고 띠가 없으면 연속이 안 읽힌다
.cs.is-demo .cs__cell {
animation-name: csBand; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 18ms);
}
@keyframes csBand {
0% { background-color: transparent; }
12% { background-color: rgba($color, .16); }
86% { background-color: transparent; }
100% { background-color: transparent; }
}
@media (max-width: 340px) {
.cs { gap: $sp-2; }
.cs__cell { height: 22px; }
.cs__stamp { width: 16px; height: 16px; }
}
// reduce 에서는 도장이 찍히는 장면을 멈추고 지금 달력 한 장만 남긴다
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__cell .cs__stamp,
.cs.is-demo .cs__cell { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cs {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.cs__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.cs__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 14px);
color: #17141a;
}
.cs__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.cs__say {
margin: 0;
border-radius: 999px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.14);
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.2;
color: #17141a;
}
.cs__days {
display: flex;
flex-direction: column;
gap: 4px;
}
.cs__row {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.cs__cell {
position: relative;
width: 100%;
height: 30px;
display: grid;
place-items: center;
border: 0;
border-radius: 8px;
padding: 0;
background: transparent;
cursor: pointer;
font-family: inherit;
color: rgba(23, 20, 26, 0.78);
}
.cs__n {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
}
.cs__cell.is-on {
background: rgba(23, 20, 26, 0.16);
color: #17141a;
}
.cs__cell.is-on .cs__n {
font-weight: 800;
}
.cs__cell.is-left {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.cs__cell.is-right {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.cs__cell:focus-visible {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.cs__stamp {
position: absolute;
right: 2px;
top: 2px;
width: 14px;
height: 14px;
color: #17141a;
opacity: 0;
transform: rotate(-12deg) scale(0.5);
}
.cs__cell.is-on .cs__stamp {
opacity: 0.95;
transform: rotate(-12deg) scale(1);
}
.cs.is-demo .cs__cell .cs__stamp {
animation-name: csStamp;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 18ms);
}
@keyframes csStamp {
0% {
opacity: 0;
transform: rotate(-42deg) scale(0.3);
}
12% {
opacity: 0.95;
transform: rotate(-12deg) scale(1);
}
76% {
opacity: 0.95;
transform: rotate(-12deg) scale(1);
}
86% {
opacity: 0;
transform: rotate(-42deg) scale(0.3);
}
100% {
opacity: 0;
transform: rotate(-42deg) scale(0.3);
}
}
.cs.is-demo .cs__cell {
animation-name: csBand;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 18ms);
}
@keyframes csBand {
0% {
background-color: transparent;
}
12% {
background-color: rgba(23, 20, 26, 0.16);
}
86% {
background-color: transparent;
}
100% {
background-color: transparent;
}
}
@media (max-width: 340px) {
.cs {
gap: 8px;
}
.cs__cell {
height: 22px;
}
.cs__stamp {
width: 16px;
height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__cell .cs__stamp,
.cs.is-demo .cs__cell {
animation: none !important;
}
}var STORE = 'godrich-habit-stamp';
var LAST_DAY = 30, COLS = 7;
var DAY_LABEL = 'Stamp for day {d}';
var SAY_TEXT = '{n} stamps';
var root = document.getElementById('cs');
var box = document.getElementById('days');
var sayEl = document.getElementById('say');
var stamped = [1, 2, 3, 5, 6, 7, 8, 11, 12, 15, 16, 17, 20, 22, 23, 26];
var cells = {};
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { stamped = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(stamped)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function has(day) { return stamped.indexOf(day) >= 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');
}
function paintAll() {
for (var d = 1; d <= LAST_DAY; d++) { paintCell(d); }
sayEl.textContent = SAY_TEXT.replace('{n}', String(stamped.length));
}
function build() {
var row = null;
for (var d = 1; d <= LAST_DAY; d++) {
if ((d - 1) % COLS === 0) {
row = document.createElement('div');
row.className = 'cs__row';
row.setAttribute('role', 'row');
box.appendChild(row);
}
var b = document.createElement('button');
b.type = 'button';
b.className = 'cs__cell';
b.setAttribute('role', 'gridcell');
b.style.setProperty('--i', String(d - 1));
b.setAttribute('aria-label', DAY_LABEL.replace('{d}', String(d)));
b.innerHTML = '<span class="cs__n">' + d + '</span>'
+ '<svg class="cs__stamp" aria-hidden="true"><use href="#ico-check-circle"/></svg>';
b.setAttribute('data-d', String(d));
cells[d] = b;
row.appendChild(b);
}
}
load();
build();
paintAll();
box.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('.cs__cell') : null;
if (!b) { return; }
root.classList.remove('is-demo');
var d = Number(b.getAttribute('data-d'));
if (has(d)) { stamped.splice(stamped.indexOf(d), 1); } else { stamped.push(d); }
save();
paintAll();
});// CalendarStamp — vanilla 판(run/297/demo/04-habit-calendar-stamp/index.html) 의 React 포트.
// 한 달 칸을 role="grid" · role="row" · role="gridcell" 로 짜고 칸마다 aria-pressed 를 둔다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CalendarStamp.module.scss";
export type CalendarStampProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type CellVars = CSSProperties & { "--i"?: string };
const STORE = "godrich-habit-stamp";
const LAST_DAY = 30;
const COLS = 7;
const TITLE = "9월 습관 도장";
const GRID_LABEL = "9월 습관 도장 달력";
const DAY_LABEL = "{d}일 도장";
const SAY_TEXT = "도장 {n}개";
const START = [1, 2, 3, 5, 6, 7, 8, 11, 12, 15, 16, 17, 20, 22, 23, 26];
export default function CalendarStamp({
duration = 2000,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#17141a",
}: CalendarStampProps) {
const [stamped, setStamped] = useState<number[]>(START);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setStamped(JSON.parse(raw) as number[]); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
}, []);
function save(next: number[]) {
try { window.localStorage.setItem(STORE, JSON.stringify(next)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function has(day: number): boolean { return stamped.indexOf(day) >= 0; }
function toggle(day: number) {
setDemo(false);
const next = has(day) ? stamped.filter((d) => d !== day) : stamped.concat([day]);
setStamped(next);
save(next);
}
const rows: number[][] = [];
for (let d = 1; d <= LAST_DAY; d++) {
if ((d - 1) % COLS === 0) { rows.push([]); }
rows[rows.length - 1].push(d);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const cellVars = (i: number): CellVars => ({ "--i": String(i) });
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-check-circle" viewBox="0 0 256 256" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></symbol>
<symbol id="ico-calendar-check" viewBox="0 0 256 256" fill="currentColor"><g><path d="M216 48v40H40V48a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16M72 48v8a8 8 0 0 0 16 0v-8h80v8a8 8 0 0 0 16 0v-8h24v32H48V48Zm136 160H48V96h160zm-38.34-85.66a8 8 0 0 1 0 11.32l-48 48a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L116 164.69l42.34-42.35a8 8 0 0 1 11.32 0"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<div className={styles.head}>
<p className={styles.title}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-calendar-check" /></svg>
<span>{TITLE}</span>
</p>
<p className={styles.say} role="status" aria-live="polite">
{SAY_TEXT.replace("{n}", String(stamped.length))}
</p>
</div>
<div className={styles.days} role="grid" aria-label={GRID_LABEL}>
{rows.map((row, ri) => (
<div className={styles.row} role="row" key={ri}>
{row.map((d) => {
const col = (d - 1) % COLS;
const on = has(d);
const cn = [
styles.cell,
on ? styles.on : "",
on && col > 0 && has(d - 1) ? styles.left : "",
on && col < COLS - 1 && has(d + 1) ? styles.right : "",
].filter(Boolean).join(" ");
return (
<button
className={cn}
key={d}
type="button"
role="gridcell"
aria-pressed={on ? "true" : "false"}
aria-label={DAY_LABEL.replace("{d}", String(d))}
style={cellVars(d - 1)}
onClick={() => toggle(d)}
>
<span className={styles.n}>{d}</span>
<svg className={styles.stamp} aria-hidden="true"><use href="#ico-check-circle" /></svg>
</button>
);
})}
</div>
))}
</div>
</div>
</main>
);
}04 · Calendar stamp — Measured area 36.483% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
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.
<main class="stage">
<div class="gr is-demo" id="gr">
<div class="gr__main">
<div class="gr__ringBox">
<svg class="gr__svg" viewBox="0 0 128 128" aria-hidden="true">
<circle class="gr__track" cx="64" cy="64" r="50"/>
<circle class="gr__track" cx="64" cy="64" r="38"/>
<circle class="gr__track" cx="64" cy="64" r="26"/>
<circle class="gr__fill gr__fill--a" id="ring0" cx="64" cy="64" r="50"/>
<circle class="gr__fill gr__fill--b" id="ring1" cx="64" cy="64" r="38"/>
<circle class="gr__fill gr__fill--c" id="ring2" cx="64" cy="64" r="26"/>
</svg>
<span class="gr__burst" aria-hidden="true">
<i class="gr__shard" style="--a:0"></i><i class="gr__shard" style="--a:1"></i>
<i class="gr__shard" style="--a:2"></i><i class="gr__shard" style="--a:3"></i>
<i class="gr__shard" style="--a:4"></i><i class="gr__shard" style="--a:5"></i>
<i class="gr__shard" style="--a:6"></i><i class="gr__shard" style="--a:7"></i>
</span>
</div>
<ul class="gr__legend">
<li class="gr__item">
<i class="gr__swatch gr__swatch--a" aria-hidden="true"></i>
<span class="gr__name">Water</span>
<b class="gr__pct" id="pct0">75%</b>
</li>
<li class="gr__item">
<i class="gr__swatch gr__swatch--b" aria-hidden="true"></i>
<span class="gr__name">Move 30 min</span>
<b class="gr__pct" id="pct1">67%</b>
</li>
<li class="gr__item">
<i class="gr__swatch gr__swatch--c" aria-hidden="true"></i>
<span class="gr__name">Read 20 pages</span>
<b class="gr__pct" id="pct2">50%</b>
</li>
</ul>
</div>
<p class="gr__say" id="say" role="status" aria-live="polite">0 of 3 goals closed</p>
<div class="gr__bar" id="bar">
<button class="gr__go" type="button" data-k="0">One glass</button>
<button class="gr__go" type="button" data-k="1">10 minutes</button>
<button class="gr__go" type="button" data-k="2">5 pages</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 삼중 목표 링 — 반지름이 다른 세 원이 각자 stroke-dasharray(둘레) 와 stroke-dashoffset(남은 길이)으로 감긴다.
// 링 끝을 stroke-linecap: round 로 둥글려 0% 에서도 점 하나가 남아 "빈 링" 과 "끊긴 링" 이 구분된다.
// 색만으로 읽히지 않게 링마다 이름과 퍼센트 숫자를 옆에 붙였다 (WCAG 1.4.1).
// 셋이 다 닫히면 조각 8개가 한 번 터진다 — 미리보기는 이 장면을 2초 안에 압축해 재생하고,
// 사람이 버튼을 누르면 JS 가 .is-demo 를 떼고 .is-done 을 잠깐 붙여 같은 폭발을 한 번만 돌린다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
.gr { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.gr__main { display: flex; align-items: center; gap: $sp-4; }
.gr__ringBox { position: relative; display: grid; place-items: center; width: 128px; height: 128px; flex: 0 0 auto; }
.gr__svg { width: 128px; height: 128px; transform: rotate(-90deg); }
.gr__track { fill: none; stroke: rgba($subject-cream, .12); stroke-width: 10; }
.gr__fill { fill: none; stroke-width: 10; stroke-linecap: round; stroke-dasharray: 314.16; stroke-dashoffset: 78; }
.gr__fill--a { stroke: $color; }
.gr__fill--b { stroke: $subject-mint; stroke-dasharray: 238.76; stroke-dashoffset: 79; }
.gr__fill--c { stroke: $subject-pink; stroke-dasharray: 163.36; stroke-dashoffset: 82; }
.gr__burst { position: absolute; width: 0; height: 0; }
.gr__shard {
position: absolute; left: -3px; top: -3px; width: 6px; height: 6px; border-radius: $r-xs;
background: $subject-cream; opacity: 0;
}
.gr__legend { display: flex; flex-direction: column; gap: $sp-2; margin: 0; padding: 0; list-style: none; flex: 1 1 auto; min-width: 0; }
.gr__item { display: flex; align-items: center; gap: $sp-2; }
.gr__swatch { width: 10px; height: 10px; border-radius: $r-xs; flex: 0 0 auto; }
.gr__swatch--a { background: $color; }
.gr__swatch--b { background: $subject-mint; }
.gr__swatch--c { background: $subject-pink; }
.gr__name {
flex: 1 1 auto; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); color: $subject-cream;
overflow: hidden; white-space: nowrap;
}
.gr__pct { font-weight: 800; font-size: clamp(11px, 2.8vw, 13px); color: $subject-cream; }
.gr__say { margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .7); }
.gr__bar { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; }
.gr__go {
border: 2px solid rgba($subject-cream, .38); border-radius: $r-pill; padding: $sp-1 $sp-3;
background: transparent; color: $subject-cream; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.6;
}
.gr__go:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 ───────────────────────────────────────────────
// 세 링이 서로 다른 속도로 감겨 66% 에 함께 닫히고, 그 순간 조각이 터진 뒤 88% 안에 처음으로 돌아온다.
.gr.is-demo .gr__fill--a {
animation-name: grRingA; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.gr.is-demo .gr__fill--b {
animation-name: grRingB; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.gr.is-demo .gr__fill--c {
animation-name: grRingC; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes grRingA {
0% { stroke-dashoffset: 314.16; }
66% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 314.16; }
100% { stroke-dashoffset: 314.16; }
}
@keyframes grRingB {
0% { stroke-dashoffset: 238.76; }
66% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 238.76; }
100% { stroke-dashoffset: 238.76; }
}
@keyframes grRingC {
0% { stroke-dashoffset: 163.36; }
66% { stroke-dashoffset: 0; }
84% { stroke-dashoffset: 163.36; }
100% { stroke-dashoffset: 163.36; }
}
.gr.is-demo .gr__shard,
.gr.is-done .gr__shard {
animation-name: grBurst; animation-duration: $duration; animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.gr.is-done .gr__shard { animation-duration: $dur-enter; animation-iteration-count: 1; }
@keyframes grBurst {
0%, 66% { transform: rotate(calc(var(--a) * 45deg)) translateY(0) scale(.4); opacity: 0; }
72% { transform: rotate(calc(var(--a) * 45deg)) translateY(-34px) scale(1); opacity: 1; }
84% { transform: rotate(calc(var(--a) * 45deg)) translateY(-62px) scale(.4); opacity: 0; }
100% { transform: rotate(calc(var(--a) * 45deg)) translateY(0) scale(.4); opacity: 0; }
}
@media (max-width: 340px) {
.gr { gap: $sp-2; }
.gr__main { gap: $sp-2; }
.gr__ringBox { width: 96px; height: 96px; }
.gr__svg { width: 96px; height: 96px; }
.gr__legend { gap: $sp-1; }
}
// reduce 에서는 폭발을 아예 끄고 링도 지금 값에서 멈춘다 — 터지는 조각은 반짝임이라 먼저 버린다
@media (prefers-reduced-motion: reduce) {
.gr.is-demo .gr__fill--a,
.gr.is-demo .gr__fill--b,
.gr.is-demo .gr__fill--c { animation: none !important; }
.gr.is-demo .gr__shard,
.gr.is-done .gr__shard { animation: none !important; opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gr {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.gr__main {
display: flex;
align-items: center;
gap: 16px;
}
.gr__ringBox {
position: relative;
display: grid;
place-items: center;
width: 128px;
height: 128px;
flex: 0 0 auto;
}
.gr__svg {
width: 128px;
height: 128px;
transform: rotate(-90deg);
}
.gr__track {
fill: none;
stroke: rgba(255, 247, 230, 0.12);
stroke-width: 10;
}
.gr__fill {
fill: none;
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 314.16;
stroke-dashoffset: 78;
}
.gr__fill--a {
stroke: #ffd23f;
}
.gr__fill--b {
stroke: #4cd4a6;
stroke-dasharray: 238.76;
stroke-dashoffset: 79;
}
.gr__fill--c {
stroke: #ff9ebb;
stroke-dasharray: 163.36;
stroke-dashoffset: 82;
}
.gr__burst {
position: absolute;
width: 0;
height: 0;
}
.gr__shard {
position: absolute;
left: -3px;
top: -3px;
width: 6px;
height: 6px;
border-radius: 4px;
background: #fff7e6;
opacity: 0;
}
.gr__legend {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
flex: 1 1 auto;
min-width: 0;
}
.gr__item {
display: flex;
align-items: center;
gap: 8px;
}
.gr__swatch {
width: 10px;
height: 10px;
border-radius: 4px;
flex: 0 0 auto;
}
.gr__swatch--a {
background: #ffd23f;
}
.gr__swatch--b {
background: #4cd4a6;
}
.gr__swatch--c {
background: #ff9ebb;
}
.gr__name {
flex: 1 1 auto;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
color: #fff7e6;
overflow: hidden;
white-space: nowrap;
}
.gr__pct {
font-weight: 800;
font-size: clamp(11px, 2.8vw, 13px);
color: #fff7e6;
}
.gr__say {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.7);
}
.gr__bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.gr__go {
border: 2px solid rgba(255, 247, 230, 0.38);
border-radius: 999px;
padding: 4px 12px;
background: transparent;
color: #fff7e6;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.6;
}
.gr__go:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.gr.is-demo .gr__fill--a {
animation-name: grRingA;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.gr.is-demo .gr__fill--b {
animation-name: grRingB;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.gr.is-demo .gr__fill--c {
animation-name: grRingC;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes grRingA {
0% {
stroke-dashoffset: 314.16;
}
66% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 314.16;
}
100% {
stroke-dashoffset: 314.16;
}
}
@keyframes grRingB {
0% {
stroke-dashoffset: 238.76;
}
66% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 238.76;
}
100% {
stroke-dashoffset: 238.76;
}
}
@keyframes grRingC {
0% {
stroke-dashoffset: 163.36;
}
66% {
stroke-dashoffset: 0;
}
84% {
stroke-dashoffset: 163.36;
}
100% {
stroke-dashoffset: 163.36;
}
}
.gr.is-demo .gr__shard,
.gr.is-done .gr__shard {
animation-name: grBurst;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.gr.is-done .gr__shard {
animation-duration: 600ms;
animation-iteration-count: 1;
}
@keyframes grBurst {
0%, 66% {
transform: rotate(calc(var(--a) * 45deg)) translateY(0) scale(0.4);
opacity: 0;
}
72% {
transform: rotate(calc(var(--a) * 45deg)) translateY(-34px) scale(1);
opacity: 1;
}
84% {
transform: rotate(calc(var(--a) * 45deg)) translateY(-62px) scale(0.4);
opacity: 0;
}
100% {
transform: rotate(calc(var(--a) * 45deg)) translateY(0) scale(0.4);
opacity: 0;
}
}
@media (max-width: 340px) {
.gr {
gap: 8px;
}
.gr__main {
gap: 8px;
}
.gr__ringBox {
width: 96px;
height: 96px;
}
.gr__svg {
width: 96px;
height: 96px;
}
.gr__legend {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.gr.is-demo .gr__fill--a,
.gr.is-demo .gr__fill--b,
.gr.is-demo .gr__fill--c {
animation: none !important;
}
.gr.is-demo .gr__shard,
.gr.is-done .gr__shard {
animation: none !important;
opacity: 0 !important;
}
}var STORE = 'godrich-habit-rings';
var GOALS = [4, 3, 2];
var RADII = [50, 38, 26];
var SAY_TEXT = '{n} of 3 goals closed';
var root = document.getElementById('gr');
var rings = [document.getElementById('ring0'), document.getElementById('ring1'), document.getElementById('ring2')];
var pcts = [document.getElementById('pct0'), document.getElementById('pct1'), document.getElementById('pct2')];
var sayEl = document.getElementById('say');
var done = [3, 2, 1];
var burstTimer = 0;
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { done = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(done)); } catch (e) { /* 저장만 건너뛴다 */ }
}
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;
}
function burst() {
root.classList.remove('is-done');
// 클래스를 뗀 직후 다시 붙이면 같은 애니메이션이 처음부터 다시 돈다
void root.offsetWidth;
root.classList.add('is-done');
window.clearTimeout(burstTimer);
burstTimer = window.setTimeout(function () { root.classList.remove('is-done'); }, 900);
}
load();
paint();
document.getElementById('bar').addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('.gr__go') : null;
if (!b) { return; }
root.classList.remove('is-demo');
var k = Number(b.getAttribute('data-k'));
done[k] = done[k] >= GOALS[k] ? 0 : done[k] + 1;
save();
if (paint()) { burst(); }
});// GoalRings — vanilla 판(run/297/demo/05-habit-goal-rings/index.html) 의 React 포트.
// 반지름이 다른 세 링을 각자의 둘레(2πr)로 감고, 셋이 다 닫히면 조각을 한 번만 터뜨린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GoalRings.module.scss";
export type GoalRingsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ShardVars = CSSProperties & { "--a"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const STORE = "godrich-habit-rings";
const GOALS = [4, 3, 2];
const RADII = [50, 38, 26];
const NAMES = ["물 마시기", "운동 30분", "독서 20쪽"];
const BUTTONS = ["물 한 잔", "운동 10분", "책 5쪽"];
const SAY_TEXT = "세 목표 중 {n}개 달성";
const SHARDS = [0, 1, 2, 3, 4, 5, 6, 7];
export default function GoalRings({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffd23f",
}: GoalRingsProps) {
const [done, setDone] = useState<number[]>([3, 2, 1]);
const [demo, setDemo] = useState(true);
const [burst, setBurst] = useState(false);
const timerRef = useRef<number>(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setDone(JSON.parse(raw) as number[]); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
return () => { window.clearTimeout(timerRef.current); };
}, []);
function save(next: number[]) {
try { window.localStorage.setItem(STORE, JSON.stringify(next)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function bump(k: number) {
setDemo(false);
const next = done.map((v, i) => (i === k ? (v >= GOALS[i] ? 0 : v + 1) : v));
setDone(next);
save(next);
const closed = next.filter((v, i) => v >= GOALS[i]).length;
if (closed === GOALS.length) {
setBurst(false);
window.clearTimeout(timerRef.current);
// 다음 프레임에 다시 붙여야 같은 애니메이션이 처음부터 돈다
timerRef.current = window.setTimeout(() => {
setBurst(true);
timerRef.current = window.setTimeout(() => setBurst(false), 900);
}, 16);
}
}
const ratios = done.map((v, i) => Math.min(v / GOALS[i], 1));
const closed = ratios.filter((p) => p >= 1).length;
const fillClass = [styles.fillA, styles.fillB, styles.fillC];
const swatchClass = [styles.swatchA, styles.swatchB, styles.swatchC];
const cls = [styles.root, demo ? styles.isDemo : "", burst ? styles.isDone : ""].filter(Boolean).join(" ");
const shardVars = (a: number): ShardVars => ({ "--a": String(a) });
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.main}>
<div className={styles.ringBox}>
<svg className={styles.svg} viewBox="0 0 128 128" aria-hidden="true">
{RADII.map((r, i) => (
<circle className={styles.track} cx="64" cy="64" r={r} key={`t${i}`} />
))}
{RADII.map((r, i) => {
const circ = 2 * Math.PI * r; // 링마다 반지름이 다르니 둘레도 따로 잰다
return (
<circle
className={`${styles.fill} ${fillClass[i]}`}
cx="64"
cy="64"
r={r}
key={`f${i}`}
style={{ strokeDasharray: String(circ), strokeDashoffset: String(circ * (1 - ratios[i])) }}
/>
);
})}
</svg>
<span className={styles.burst} aria-hidden="true">
{SHARDS.map((a) => <i className={styles.shard} key={a} style={shardVars(a)} />)}
</span>
</div>
<ul className={styles.legend}>
{NAMES.map((n, i) => (
<li className={styles.item} key={i}>
<i className={`${styles.swatch} ${swatchClass[i]}`} aria-hidden="true" />
<span className={styles.name}>{n}</span>
<b className={styles.pct}>{Math.round(ratios[i] * 100)}%</b>
</li>
))}
</ul>
</div>
<p className={styles.say} role="status" aria-live="polite">
{SAY_TEXT.replace("{n}", String(closed))}
</p>
<div className={styles.bar}>
{BUTTONS.map((label, i) => (
<button className={styles.go} type="button" key={i} onClick={() => bump(i)}>{label}</button>
))}
</div>
</div>
</main>
);
}05 · Triple goal rings — Measured area 5.603% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
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.
<main class="stage">
<div class="wb is-demo" id="wb">
<div class="wb__head">
<p class="wb__title">Done per day</p>
<p class="wb__say" id="say" role="status" aria-live="polite">4 days above the goal line</p>
</div>
<div class="wb__chart">
<span class="wb__goal" aria-hidden="true"></span>
<div class="wb__cols" id="cols"></div>
</div>
<p class="wb__cap">Left to right: day 1 through day 7 of this week</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 요일별 막대 요약 — 칸은 grid-template-columns: repeat(7, 1fr) 로 정확히 균등 분할한다
// (flex: 1 1 0 위에 scaleX 로 채우면 BD19 가 탭 인디케이터로 오해한다 — 254-05 계열).
// 막대는 아래 변에 붙어 자라는 것이라 transform-origin 을 bottom 에 두고 scaleY 로만 키운다.
// 목표선을 넘은 막대는 색이 갈리지만 그 위에 숫자를 늘 적어 두므로 색 없이도 읽힌다.
// 미리보기(.is-demo)는 왼쪽 막대부터 차례로 자라고, 사람이 칸을 누르면 그 요일 수가 올라간다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.wb { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.wb__head { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.wb__title { margin: 0; font-weight: 800; font-size: clamp(13px, 3.4vw, 15px); color: $subject-ink; }
.wb__say { margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .62); }
.wb__chart { position: relative; }
// 목표선은 최대 5회 중 3회 자리 — 막대 칸(96px) 의 60%(57.6px) 위, 눈금 줄 높이 20px 을 더해 78px
.wb__goal {
position: absolute; left: 0; right: 0; bottom: 78px; height: 0;
border-top: 2px dashed rgba($subject-ink, .32);
}
.wb__cols { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-2; }
.wb__col {
position: relative; width: 100%; display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border: 0; border-radius: $r-sm; padding: 0; background: transparent; cursor: pointer;
font-family: inherit; color: $subject-ink;
}
.wb__num { font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1; }
.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));
}
.wb__bar.is-over { background: $color; }
.wb__tick { font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); line-height: 1.6; color: rgba($subject-ink, .68); }
// 오늘 칸은 눈금 자리에 날짜 대신 「오늘」을 적는다 — 칸 밖에 알약을 띄우면 눈금 글자를 덮는다
.wb__tick.is-today {
border-radius: $r-pill; padding: 0 $sp-1; background: $subject-ink; color: $stage-paper;
font-weight: 800;
}
.wb__col.is-today .wb__slot { box-shadow: inset 0 0 0 2px $subject-ink; }
.wb__col:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.wb__cap { margin: 0; font-weight: 700; font-size: clamp(8px, 2.2vw, 10px); color: rgba($subject-ink, .7); }
// ── 격자 미리보기 ───────────────────────────────────────────────
.wb.is-demo .wb__bar {
animation-name: wbGrow; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 60ms);
}
// 바닥까지 완전히 내리지 않고 20% 만 남긴다 — 어느 프레임을 뽑아도 일곱 막대가 다 보이게
@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)); }
}
@media (max-width: 340px) {
.wb { gap: $sp-2; }
.wb__cols { gap: $sp-1; }
.wb__slot { height: 60px; }
.wb__goal { bottom: 53px; }
}
// reduce 에서는 막대가 자라는 장면을 끄고 지금 값 그대로 세워 둔다
@media (prefers-reduced-motion: reduce) {
.wb.is-demo .wb__bar { animation: none !important; transform: scaleY(var(--v, 0)) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wb {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.wb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.wb__title {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 15px);
color: #17141a;
}
.wb__say {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.62);
}
.wb__chart {
position: relative;
}
.wb__goal {
position: absolute;
left: 0;
right: 0;
bottom: 78px;
height: 0;
border-top: 2px dashed rgba(23, 20, 26, 0.32);
}
.wb__cols {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.wb__col {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: 0;
border-radius: 8px;
padding: 0;
background: transparent;
cursor: pointer;
font-family: inherit;
color: #17141a;
}
.wb__num {
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
}
.wb__slot {
display: block;
width: 100%;
height: 96px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.08);
}
.wb__bar {
display: block;
width: 100%;
height: 100%;
border-radius: 8px;
background: rgba(23, 20, 26, 0.3);
transform-origin: bottom;
transform: scaleY(var(--v, 0));
}
.wb__bar.is-over {
background: #2f6df6;
}
.wb__tick {
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.6;
color: rgba(23, 20, 26, 0.68);
}
.wb__tick.is-today {
border-radius: 999px;
padding: 0 4px;
background: #17141a;
color: #ffffff;
font-weight: 800;
}
.wb__col.is-today .wb__slot {
box-shadow: inset 0 0 0 2px #17141a;
}
.wb__col:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.wb__cap {
margin: 0;
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
color: rgba(23, 20, 26, 0.7);
}
.wb.is-demo .wb__bar {
animation-name: wbGrow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 60ms);
}
@keyframes wbGrow {
0% {
transform: scaleY(calc(var(--v, 0) * 0.2));
}
24% {
transform: scaleY(var(--v, 0));
}
76% {
transform: scaleY(var(--v, 0));
}
90% {
transform: scaleY(calc(var(--v, 0) * 0.2));
}
100% {
transform: scaleY(calc(var(--v, 0) * 0.2));
}
}
@media (max-width: 340px) {
.wb {
gap: 8px;
}
.wb__cols {
gap: 4px;
}
.wb__slot {
height: 60px;
}
.wb__goal {
bottom: 53px;
}
}
@media (prefers-reduced-motion: reduce) {
.wb.is-demo .wb__bar {
animation: none !important;
transform: scaleY(var(--v, 0)) !important;
}
}var STORE = 'godrich-habit-bars';
var MAX = 5, GOAL = 3, TODAY = 4; // 목표 3회, 오늘은 5일차(0부터 세어 4)
var COL_LABEL = 'Day {d}, {n} times';
var SAY_TEXT = '{n} days above the goal line';
var TODAY_TAG = 'Today';
var root = document.getElementById('wb');
var cols = document.getElementById('cols');
var sayEl = document.getElementById('say');
var counts = [2, 4, 3, 1, 3, 5, 2];
var bars = [], nums = [], buttons = [];
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { counts = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(counts)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function paint() {
var over = 0;
for (var i = 0; i < counts.length; i++) {
var v = Math.min(counts[i] / MAX, 1);
bars[i].style.setProperty('--v', String(v));
bars[i].className = 'wb__bar' + (counts[i] >= GOAL ? ' is-over' : '');
nums[i].textContent = String(counts[i]);
buttons[i].setAttribute('aria-label', COL_LABEL.replace('{d}', String(i + 1)).replace('{n}', String(counts[i])));
if (counts[i] >= GOAL) { over++; }
}
sayEl.textContent = SAY_TEXT.replace('{n}', String(over));
}
function build() {
for (var i = 0; i < counts.length; i++) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'wb__col' + (i === TODAY ? ' is-today' : '');
b.style.setProperty('--i', String(i));
b.setAttribute('data-i', String(i));
b.innerHTML = '<span class="wb__num"></span>'
+ '<span class="wb__slot"><span class="wb__bar"></span></span>'
+ '<span class="wb__tick' + (i === TODAY ? ' is-today' : '') + '">'
+ (i === TODAY ? TODAY_TAG : String(i + 1)) + '</span>';
bars.push(b.querySelector('.wb__bar'));
nums.push(b.querySelector('.wb__num'));
buttons.push(b);
cols.appendChild(b);
}
}
load();
build();
paint();
cols.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('.wb__col') : null;
if (!b) { return; }
root.classList.remove('is-demo');
var i = Number(b.getAttribute('data-i'));
counts[i] = counts[i] >= MAX ? 0 : counts[i] + 1;
save();
paint();
});// WeekdayBars — vanilla 판(run/297/demo/06-habit-weekly-bars/index.html) 의 React 포트.
// 칸은 repeat(7, 1fr) 로 균등 분할하고 막대는 아래 변에서 scaleY 로만 자란다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WeekdayBars.module.scss";
export type WeekdayBarsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ColVars = CSSProperties & { "--i"?: string; "--v"?: string };
const STORE = "godrich-habit-bars";
const MAX = 5;
const GOAL = 3;
const TODAY = 4; // 목표 3회, 오늘은 5일차(0부터 세어 4)
const TITLE = "요일별 완료 수";
const COL_LABEL = "{d}일차 {n}회";
const SAY_TEXT = "목표선을 넘은 날 {n}개";
const TODAY_TAG = "오늘";
const CAP_TEXT = "왼쪽부터 이번 주 1일차부터 7일차까지";
const START = [2, 4, 3, 1, 3, 5, 2];
export default function WeekdayBars({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: WeekdayBarsProps) {
const [counts, setCounts] = useState<number[]>(START);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setCounts(JSON.parse(raw) as number[]); }
} catch (e) { /* 저장소가 막힌 창에서는 기본 기록 그대로 */ }
}, []);
function save(next: number[]) {
try { window.localStorage.setItem(STORE, JSON.stringify(next)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function bump(i: number) {
setDemo(false);
const next = counts.map((v, k) => (k === i ? (v >= MAX ? 0 : v + 1) : v));
setCounts(next);
save(next);
}
const over = counts.filter((n) => n >= GOAL).length;
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const colVars = (i: number, n: number): ColVars => ({ "--i": String(i), "--v": String(Math.min(n / MAX, 1)) });
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.head}>
<p className={styles.title}>{TITLE}</p>
<p className={styles.say} role="status" aria-live="polite">
{SAY_TEXT.replace("{n}", String(over))}
</p>
</div>
<div className={styles.chart}>
<span className={styles.goal} aria-hidden="true" />
<div className={styles.cols}>
{counts.map((n, i) => (
<button
className={[styles.col, i === TODAY ? styles.colToday : ""].filter(Boolean).join(" ")}
key={i}
type="button"
style={colVars(i, n)}
aria-label={COL_LABEL.replace("{d}", String(i + 1)).replace("{n}", String(n))}
onClick={() => bump(i)}
>
<span className={styles.num}>{n}</span>
<span className={styles.slot}>
<span className={[styles.bar, n >= GOAL ? styles.over : ""].filter(Boolean).join(" ")} />
</span>
<span className={[styles.tick, i === TODAY ? styles.tickToday : ""].filter(Boolean).join(" ")}>
{i === TODAY ? TODAY_TAG : String(i + 1)}
</span>
</button>
))}
</div>
</div>
<p className={styles.cap}>{CAP_TEXT}</p>
</div>
</main>
);
}06 · Weekday bar summary — Measured area 12.28% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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.
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-bell" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216M48 184c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="rp is-demo" id="rp">
<div class="rp__card">
<div class="rp__head">
<p class="rp__title">
<svg class="rp__bell" id="bell" aria-hidden="true"><use href="#ico-bell"/></svg>
<span>Reminder</span>
</p>
<span class="rp__swWrap">
<span class="rp__swText">Notify me</span>
<button class="rp__switch" id="sw" type="button" role="switch" aria-checked="true" aria-label="Notify me">
<span class="rp__knob" aria-hidden="true"></span>
</button>
</span>
</div>
<div class="rp__chips" id="chips"></div>
<p class="rp__say" id="say" role="status" aria-live="polite">Next reminder in 3 h 12 m</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 리마인더 시간 고르기 — 시각 칩은 aria-pressed 로 고른 상태를, 스위치는 role="switch" 와 aria-checked 로
// 켜짐을 그대로 알린다. 남은 시간은 고른 시각이 오늘 지났으면 내일로 넘겨 계산한다.
// 미리보기(.is-demo)는 선택이 칩을 하나씩 건너뛰는 장면을 steps(1, end) 컷으로 재생한다 —
// 여기서는 fill-mode 를 붙이지 않는다. 지연 구간에 보여야 할 그림이 칩의 작성 상태(안 고른 칩) 그대로라,
// backwards 를 붙이면 오히려 아직 차례가 안 온 칩이 전부 켜진 채로 시작한다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.rp { display: flex; width: 100%; max-width: 424px; }
.rp__card {
flex: 1 1 auto; display: flex; flex-direction: column; gap: $sp-3;
border-radius: $r-card; padding: $sp-4; background: $stage-paper;
box-shadow: 0 6px 20px rgba($subject-ink, .1), inset 0 0 0 1px rgba($subject-ink, .08);
}
.rp__head { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.rp__title {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(12px, 3.2vw, 14px); color: $subject-ink;
}
// 종은 천장에 매달린 것이라 회전축을 위쪽 중앙에 둔다 (스스로 완결한 아이콘의 scale 과는 다른 경우)
.rp__bell { width: 18px; height: 18px; flex: 0 0 auto; color: $color; transform-origin: 50% 15%; }
.rp__swWrap { display: inline-flex; align-items: center; gap: $sp-2; }
.rp__swText { font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .62); }
.rp__switch {
position: relative; width: 40px; height: 22px; flex: 0 0 auto; border: 0; border-radius: $r-pill;
padding: 0; background: rgba($subject-ink, .2); cursor: pointer;
}
.rp__switch[aria-checked="true"] { background: $color; }
.rp__knob {
position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; border-radius: 50%;
background: $stage-paper;
}
.rp__switch[aria-checked="true"] .rp__knob { transform: translateX(18px); }
.rp__switch:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.rp__chips { display: flex; flex-wrap: wrap; gap: $sp-2; }
.rp__chip {
border: 2px solid rgba($subject-ink, .18); border-radius: $r-pill; padding: $sp-1 $sp-3;
background: transparent; color: $subject-ink; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 12px); line-height: 1.6;
}
.rp__chip[aria-pressed="true"] { border-color: $color; background: $color; color: $stage-paper; }
.rp__chip:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.rp__say { margin: 0; font-weight: 700; font-size: clamp(10px, 2.6vw, 12px); color: rgba($subject-ink, .68); }
// ── 격자 미리보기 ───────────────────────────────────────────────
// 칩 하나가 400ms 씩 차례로 켜진다. 값이 단계라 중간값 없이 컷으로 넘긴다.
.rp.is-demo .rp__chip {
animation-name: rpHop; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 400ms);
}
@keyframes rpHop {
0% { border-color: $color; background-color: $color; color: $stage-paper; }
20% { border-color: rgba($subject-ink, .18); background-color: transparent; color: $subject-ink; }
100% { border-color: rgba($subject-ink, .18); background-color: transparent; color: $subject-ink; }
}
.rp.is-demo .rp__bell {
animation-name: rpRing; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes rpRing {
0% { transform: rotate(0deg); }
10% { transform: rotate(-14deg); }
24% { transform: rotate(12deg); }
38% { transform: rotate(-8deg); }
52% { transform: rotate(6deg); }
70% { transform: rotate(0deg); }
100% { transform: rotate(0deg); }
}
.rp.is-demo .rp__knob {
animation-name: rpKnob; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes rpKnob {
0% { transform: translateX(18px); }
46% { transform: translateX(18px); }
58% { transform: translateX(0); }
84% { transform: translateX(18px); }
100% { transform: translateX(18px); }
}
@media (max-width: 340px) {
.rp__card { padding: $sp-3; gap: $sp-2; }
.rp__chips { gap: $sp-1; }
.rp__chip { padding: $sp-1 $sp-2; }
.rp__switch { width: 32px; height: 18px; }
.rp__knob { width: 12px; height: 12px; }
.rp__switch[aria-checked="true"] .rp__knob { transform: translateX(14px); }
}
// reduce 에서는 종·스위치·칩 순회를 모두 멈추고 고른 시각 한 장만 남긴다
@media (prefers-reduced-motion: reduce) {
.rp.is-demo .rp__chip,
.rp.is-demo .rp__bell,
.rp.is-demo .rp__knob { animation: none !important; }
.rp.is-demo .rp__bell { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rp {
display: flex;
width: 100%;
max-width: 424px;
}
.rp__card {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 12px;
border-radius: 16px;
padding: 16px;
background: #ffffff;
box-shadow: 0 6px 20px rgba(23, 20, 26, 0.1), inset 0 0 0 1px rgba(23, 20, 26, 0.08);
}
.rp__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.rp__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 14px);
color: #17141a;
}
.rp__bell {
width: 18px;
height: 18px;
flex: 0 0 auto;
color: #2f6df6;
transform-origin: 50% 15%;
}
.rp__swWrap {
display: inline-flex;
align-items: center;
gap: 8px;
}
.rp__swText {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.62);
}
.rp__switch {
position: relative;
width: 40px;
height: 22px;
flex: 0 0 auto;
border: 0;
border-radius: 999px;
padding: 0;
background: rgba(23, 20, 26, 0.2);
cursor: pointer;
}
.rp__switch[aria-checked=true] {
background: #2f6df6;
}
.rp__knob {
position: absolute;
left: 3px;
top: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffffff;
}
.rp__switch[aria-checked=true] .rp__knob {
transform: translateX(18px);
}
.rp__switch:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.rp__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.rp__chip {
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 999px;
padding: 4px 12px;
background: transparent;
color: #17141a;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
line-height: 1.6;
}
.rp__chip[aria-pressed=true] {
border-color: #2f6df6;
background: #2f6df6;
color: #ffffff;
}
.rp__chip:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.rp__say {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
color: rgba(23, 20, 26, 0.68);
}
.rp.is-demo .rp__chip {
animation-name: rpHop;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 400ms);
}
@keyframes rpHop {
0% {
border-color: #2f6df6;
background-color: #2f6df6;
color: #ffffff;
}
20% {
border-color: rgba(23, 20, 26, 0.18);
background-color: transparent;
color: #17141a;
}
100% {
border-color: rgba(23, 20, 26, 0.18);
background-color: transparent;
color: #17141a;
}
}
.rp.is-demo .rp__bell {
animation-name: rpRing;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes rpRing {
0% {
transform: rotate(0deg);
}
10% {
transform: rotate(-14deg);
}
24% {
transform: rotate(12deg);
}
38% {
transform: rotate(-8deg);
}
52% {
transform: rotate(6deg);
}
70% {
transform: rotate(0deg);
}
100% {
transform: rotate(0deg);
}
}
.rp.is-demo .rp__knob {
animation-name: rpKnob;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes rpKnob {
0% {
transform: translateX(18px);
}
46% {
transform: translateX(18px);
}
58% {
transform: translateX(0);
}
84% {
transform: translateX(18px);
}
100% {
transform: translateX(18px);
}
}
@media (max-width: 340px) {
.rp__card {
padding: 12px;
gap: 8px;
}
.rp__chips {
gap: 4px;
}
.rp__chip {
padding: 4px 8px;
}
.rp__switch {
width: 32px;
height: 18px;
}
.rp__knob {
width: 12px;
height: 12px;
}
.rp__switch[aria-checked=true] .rp__knob {
transform: translateX(14px);
}
}
@media (prefers-reduced-motion: reduce) {
.rp.is-demo .rp__chip,
.rp.is-demo .rp__bell,
.rp.is-demo .rp__knob {
animation: none !important;
}
.rp.is-demo .rp__bell {
transform: none !important;
}
}var STORE = 'godrich-habit-reminder';
var TIMES = ['06:00', '07:30', '12:00', '18:30', '21:30'];
var CHIP_LABEL = 'Remind at {t}';
var LEFT_TEXT = 'Next reminder in {h} h {m} m';
var OFF_TEXT = 'Reminders are off';
var root = document.getElementById('rp');
var chipBox = document.getElementById('chips');
var sw = document.getElementById('sw');
var sayEl = document.getElementById('say');
var state = { pick: 1, on: true };
var chips = [];
var tick = 0;
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { state = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본값 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(state)); } catch (e) { /* 저장만 건너뛴다 */ }
}
// 고른 시각이 Today 이미 지났으면 내일 같은 시각까지를 센다
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;
}
function paint() {
for (var i = 0; i < chips.length; i++) {
chips[i].setAttribute('aria-pressed', i === state.pick ? 'true' : 'false');
}
sw.setAttribute('aria-checked', state.on ? 'true' : 'false');
if (!state.on) { sayEl.textContent = OFF_TEXT; return; }
var left = minutesLeft(TIMES[state.pick]);
sayEl.textContent = LEFT_TEXT.replace('{h}', String(Math.floor(left / 60))).replace('{m}', String(left % 60));
}
function build() {
for (var i = 0; i < TIMES.length; i++) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'rp__chip';
b.style.setProperty('--i', String(i));
b.textContent = TIMES[i];
b.setAttribute('aria-pressed', 'false');
b.setAttribute('aria-label', CHIP_LABEL.replace('{t}', TIMES[i]));
b.setAttribute('data-i', String(i));
chips.push(b);
chipBox.appendChild(b);
}
}
load();
build();
paint();
chipBox.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('.rp__chip') : null;
if (!b) { return; }
root.classList.remove('is-demo');
state.pick = Number(b.getAttribute('data-i'));
save();
paint();
});
sw.addEventListener('click', function () {
root.classList.remove('is-demo');
state.on = !state.on;
save();
paint();
});
tick = window.setInterval(paint, 30000);// ReminderPicker — vanilla 판(run/297/demo/07-habit-reminder-picker/index.html) 의 React 포트.
// 고른 시각이 오늘 이미 지났으면 내일 같은 시각까지를 센다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ReminderPicker.module.scss";
export type ReminderPickerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChipVars = CSSProperties & { "--i"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const STORE = "godrich-habit-reminder";
const TIMES = ["06:00", "07:30", "12:00", "18:30", "21:30"];
const TITLE = "리마인더";
const SWITCH_TEXT = "알림 켜기";
const CHIP_LABEL = "{t} 알림";
const LEFT_TEXT = "다음 알림까지 {h}시간 {m}분";
const OFF_TEXT = "알림이 꺼져 있습니다";
// 고른 시각이 오늘 이미 지났으면 내일 같은 시각까지를 센다
function minutesLeft(hhmm: string): number {
const parts = hhmm.split(":");
const now = new Date();
const at = new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(parts[0]), Number(parts[1]), 0, 0);
const diff = Math.round((at.getTime() - now.getTime()) / 60000);
return diff <= 0 ? diff + 24 * 60 : diff;
}
export default function ReminderPicker({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: ReminderPickerProps) {
const [pick, setPick] = useState(1);
const [on, setOn] = useState(true);
const [now, setNow] = useState(0);
const [demo, setDemo] = useState(true);
const timerRef = useRef<number>(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) {
const saved = JSON.parse(raw) as { pick: number; on: boolean };
setPick(saved.pick);
setOn(saved.on);
}
} catch (e) { /* 저장소가 막힌 창에서는 기본값 그대로 */ }
timerRef.current = window.setInterval(() => setNow((n) => n + 1), 30000);
return () => { window.clearInterval(timerRef.current); };
}, []);
function save(nextPick: number, nextOn: boolean) {
try {
window.localStorage.setItem(STORE, JSON.stringify({ pick: nextPick, on: nextOn }));
} catch (e) { /* 저장만 건너뛴다 */ }
}
function choose(i: number) {
setDemo(false);
setPick(i);
save(i, on);
}
function flip() {
setDemo(false);
setOn(!on);
save(pick, !on);
}
const left = minutesLeft(TIMES[pick]);
const say = on
? LEFT_TEXT.replace("{h}", String(Math.floor(left / 60))).replace("{m}", String(left % 60))
: OFF_TEXT;
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const chipVars = (i: number): ChipVars => ({ "--i": String(i) });
void now; // 30초마다 다시 그리게 하는 값 — 표시는 minutesLeft 가 그때 다시 계산한다
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-bell" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216M48 184c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.head}>
<p className={styles.title}>
<svg className={styles.bell} aria-hidden="true"><use href="#ico-bell" /></svg>
<span>{TITLE}</span>
</p>
<span className={styles.swWrap}>
<span className={styles.swText}>{SWITCH_TEXT}</span>
<button
className={styles.switch}
type="button"
role="switch"
aria-checked={on ? "true" : "false"}
aria-label={SWITCH_TEXT}
onClick={flip}
>
<span className={styles.knob} aria-hidden="true" />
</button>
</span>
</div>
<div className={styles.chips}>
{TIMES.map((t, i) => (
<button
className={styles.chip}
key={t}
type="button"
aria-pressed={i === pick ? "true" : "false"}
aria-label={CHIP_LABEL.replace("{t}", t)}
style={chipVars(i)}
onClick={() => choose(i)}
>
{t}
</button>
))}
</div>
<p className={styles.say} role="status" aria-live="polite">{say}</p>
</div>
</div>
</main>
);
}07 · Reminder time picker — Measured area 6.332% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
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.
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-medal" viewBox="0 0 256 256" fill="currentColor"><g><path d="M176 96a48 48 0 1 1-48-48a48 48 0 0 1 48 48" opacity=".2"/><path d="M216 96a88 88 0 1 0-144 67.83V240a8 8 0 0 0 11.58 7.16L128 225l44.43 22.21a8.1 8.1 0 0 0 3.57.79a8 8 0 0 0 8-8v-76.17A87.85 87.85 0 0 0 216 96M56 96a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 131.06l-36.43-18.21a8 8 0 0 0-7.16 0L88 227.06v-52.69a87.89 87.89 0 0 0 80 0ZM128 152a56 56 0 1 0-56-56a56.06 56.06 0 0 0 56 56m0-96a40 40 0 1 1-40 40a40 40 0 0 1 40-40"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="bu is-demo" id="bu">
<div class="bu__head">
<p class="bu__title">Badges</p>
<p class="bu__say" id="say" role="status" aria-live="polite">12 days now · 1 badge open</p>
</div>
<div class="bu__row" id="row"></div>
<div class="bu__bar">
<button class="bu__go" id="plus1" type="button">One more day</button>
<button class="bu__go" id="plus10" type="button">Ten more days</button>
<button class="bu__reset" id="reset" type="button">Reset</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 배지 잠금 해제 — 앞면(잠김)·뒷면(해제)을 한 상자에 겹치고 transform-style: preserve-3d 로 세운다.
// 각 면은 backface-visibility: hidden 이라 돌아간 면이 비쳐 보이지 않고, 잠김 면은 grayscale(1) 로 색을 뺀다.
// 색만으로 잠김을 알리지 않으려고 앞면에 「잠김」 글자를 함께 적는다.
// 실제 클릭 경로는 transition 으로 부드럽게 돌지만, 격자 미리보기의 뒤집기는 steps(1, end) 컷이다 —
// rotateY 로 돌아가는 면의 폭은 |cos θ| 라 프레임당 변화가 90° 부근에서 늘 최대가 되어
// poster 가 매번 모서리뷰(선 한 줄)로 뽑히기 때문이다 (252-07 계열).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
.bu { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.bu__head { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.bu__title { margin: 0; font-weight: 800; font-size: clamp(13px, 3.4vw, 15px); color: $subject-cream; }
.bu__say { margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .66); }
.bu__row { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-3; }
.bu__badge {
width: 100%; height: 104px; border: 0; border-radius: $r-card; padding: 0;
background: transparent; cursor: pointer; perspective: 600px;
}
.bu__badge:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.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;
}
.bu__face--lock {
background: rgba($subject-cream, .08); color: rgba($subject-cream, .5);
filter: grayscale(1);
}
.bu__face--win {
background: $color; color: $stage-ink; transform: rotateY(180deg);
}
.bu__ico { width: 30px; height: 30px; }
.bu__mark { font-weight: 800; font-size: clamp(15px, 4vw, 19px); line-height: 1; }
.bu__lock { font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); line-height: 1; }
// 반짝임은 배지 위를 가로지르는 밝은 띠 하나다 — 해제된 면에만 얹는다
.bu__shine {
position: absolute; left: -60%; top: 0; width: 50%; height: 100%;
background: linear-gradient(100deg, rgba($subject-cream, 0), rgba($subject-cream, .85), rgba($subject-cream, 0));
opacity: 0;
}
.bu__bar { display: flex; align-items: center; gap: $sp-2; flex-wrap: wrap; }
.bu__go, .bu__reset {
border-radius: $r-pill; padding: $sp-1 $sp-3; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.6;
}
.bu__go { border: 0; background: $color; color: $stage-ink; }
.bu__reset { border: 2px solid rgba($subject-cream, .38); background: transparent; color: $subject-cream; }
.bu__go:focus-visible, .bu__reset:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 격자 미리보기 ───────────────────────────────────────────────
// 배지가 하나씩 열리고 열린 배지 위로 반짝임이 지나간다. 뒤집기는 컷, 반짝임은 이징이라
// 프레임마다 움직이는 그림이 계속 남는다 (컷만 쓰면 움직인 프레임이 두세 장으로 떨어진다).
.bu.is-demo .bu__inner {
animation-name: buFlip; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 220ms);
}
@keyframes buFlip {
0% { transform: rotateY(0deg); }
16% { transform: rotateY(180deg); }
78% { transform: rotateY(180deg); }
88% { transform: rotateY(0deg); }
100% { transform: rotateY(0deg); }
}
.bu.is-demo .bu__shine {
animation-name: buShine; animation-duration: $duration; animation-timing-function: $ease-out;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 220ms);
}
@keyframes buShine {
0%, 16% { transform: translateX(0); opacity: 0; }
22% { transform: translateX(40px); opacity: .9; }
60% { transform: translateX(220px); opacity: .9; }
70% { transform: translateX(260px); opacity: 0; }
100% { transform: translateX(0); opacity: 0; }
}
@media (max-width: 340px) {
.bu { gap: $sp-2; }
.bu__row { gap: $sp-2; }
.bu__badge { height: 72px; }
.bu__ico { width: 20px; height: 20px; }
}
// reduce 에서는 뒤집기와 반짝임을 모두 끄고 지금 열림 상태 한 장만 남긴다
@media (prefers-reduced-motion: reduce) {
.bu.is-demo .bu__inner { animation: none !important; }
.bu.is-demo .bu__shine { animation: none !important; opacity: 0 !important; }
.bu__inner { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bu {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.bu__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.bu__title {
margin: 0;
font-weight: 800;
font-size: clamp(13px, 3.4vw, 15px);
color: #fff7e6;
}
.bu__say {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.66);
}
.bu__row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.bu__badge {
width: 100%;
height: 104px;
border: 0;
border-radius: 16px;
padding: 0;
background: transparent;
cursor: pointer;
perspective: 600px;
}
.bu__badge:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.bu__inner {
position: relative;
display: block;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.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: 4px;
border-radius: 16px;
backface-visibility: hidden;
overflow: hidden;
}
.bu__face--lock {
background: rgba(255, 247, 230, 0.08);
color: rgba(255, 247, 230, 0.5);
filter: grayscale(1);
}
.bu__face--win {
background: #ffd23f;
color: #17141a;
transform: rotateY(180deg);
}
.bu__ico {
width: 30px;
height: 30px;
}
.bu__mark {
font-weight: 800;
font-size: clamp(15px, 4vw, 19px);
line-height: 1;
}
.bu__lock {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1;
}
.bu__shine {
position: absolute;
left: -60%;
top: 0;
width: 50%;
height: 100%;
background: linear-gradient(100deg, rgba(255, 247, 230, 0), rgba(255, 247, 230, 0.85), rgba(255, 247, 230, 0));
opacity: 0;
}
.bu__bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bu__go, .bu__reset {
border-radius: 999px;
padding: 4px 12px;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.6;
}
.bu__go {
border: 0;
background: #ffd23f;
color: #17141a;
}
.bu__reset {
border: 2px solid rgba(255, 247, 230, 0.38);
background: transparent;
color: #fff7e6;
}
.bu__go:focus-visible, .bu__reset:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.bu.is-demo .bu__inner {
animation-name: buFlip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 220ms);
}
@keyframes buFlip {
0% {
transform: rotateY(0deg);
}
16% {
transform: rotateY(180deg);
}
78% {
transform: rotateY(180deg);
}
88% {
transform: rotateY(0deg);
}
100% {
transform: rotateY(0deg);
}
}
.bu.is-demo .bu__shine {
animation-name: buShine;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 220ms);
}
@keyframes buShine {
0%, 16% {
transform: translateX(0);
opacity: 0;
}
22% {
transform: translateX(40px);
opacity: 0.9;
}
60% {
transform: translateX(220px);
opacity: 0.9;
}
70% {
transform: translateX(260px);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 0;
}
}
@media (max-width: 340px) {
.bu {
gap: 8px;
}
.bu__row {
gap: 8px;
}
.bu__badge {
height: 72px;
}
.bu__ico {
width: 20px;
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.bu.is-demo .bu__inner {
animation: none !important;
}
.bu.is-demo .bu__shine {
animation: none !important;
opacity: 0 !important;
}
.bu__inner {
transition: none !important;
}
}var STORE = 'godrich-habit-badge';
var MARKS = [7, 30, 100];
var BADGE_LABEL = '{n}-day badge';
var LOCK_TEXT = 'Locked';
var SAY_TEXT = '{d} days now · {n} badges open';
var root = document.getElementById('bu');
var row = document.getElementById('row');
var sayEl = document.getElementById('say');
var days = 12;
var badges = [];
function load() {
try { days = Number(window.localStorage.getItem(STORE)) || days; } catch (e) { /* 기본값 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, String(days)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function paint() {
var open = 0;
for (var i = 0; i < badges.length; i++) {
var on = days >= MARKS[i];
badges[i].className = 'bu__badge' + (on ? ' is-open' : '');
badges[i].setAttribute('aria-pressed', on ? 'true' : 'false');
if (on) { open++; }
}
sayEl.textContent = SAY_TEXT.replace('{d}', String(days)).replace('{n}', String(open));
}
function build() {
for (var i = 0; i < MARKS.length; i++) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'bu__badge';
b.style.setProperty('--i', String(i));
b.setAttribute('aria-pressed', 'false');
b.setAttribute('aria-label', BADGE_LABEL.replace('{n}', String(MARKS[i])));
b.innerHTML = '<span class="bu__inner">'
+ '<span class="bu__face bu__face--lock"><b class="bu__mark">' + MARKS[i] + '</b><span class="bu__lock">' + LOCK_TEXT + '</span></span>'
+ '<span class="bu__face bu__face--win"><svg class="bu__ico" aria-hidden="true"><use href="#ico-medal"/></svg>'
+ '<b class="bu__mark">' + MARKS[i] + '</b><span class="bu__shine" aria-hidden="true"></span></span>'
+ '</span>';
badges.push(b);
row.appendChild(b);
}
}
load();
build();
paint();
function bump(n) {
root.classList.remove('is-demo');
days = Math.max(0, days + n);
save();
paint();
}
document.getElementById('plus1').addEventListener('click', function () { bump(1); });
document.getElementById('plus10').addEventListener('click', function () { bump(10); });
document.getElementById('reset').addEventListener('click', function () { bump(-days); });// BadgeUnlock — vanilla 판(run/297/demo/08-habit-badge-unlock/index.html) 의 React 포트.
// 앞면(잠김)·뒷면(해제)을 preserve-3d 로 겹치고 각 면은 backface-visibility: hidden 이다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BadgeUnlock.module.scss";
export type BadgeUnlockProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BadgeVars = CSSProperties & { "--i"?: string };
const STORE = "godrich-habit-badge";
const MARKS = [7, 30, 100];
const TITLE = "성취 배지";
const BADGE_LABEL = "{n}일 배지";
const LOCK_TEXT = "잠김";
const SAY_TEXT = "지금 {d}일 · 배지 {n}개 열림";
const PLUS1_TEXT = "하루 더";
const PLUS10_TEXT = "열흘 더";
const RESET_TEXT = "처음으로";
export default function BadgeUnlock({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffd23f",
}: BadgeUnlockProps) {
const [days, setDays] = useState(12);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setDays(Number(raw) || 0); }
} catch (e) { /* 기본값 그대로 */ }
}, []);
function bump(n: number) {
setDemo(false);
const next = Math.max(0, n < 0 ? 0 : days + n);
setDays(next);
try { window.localStorage.setItem(STORE, String(next)); } catch (e) { /* 저장만 건너뛴다 */ }
}
const open = MARKS.filter((m) => days >= m).length;
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const badgeVars = (i: number): BadgeVars => ({ "--i": String(i) });
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-medal" viewBox="0 0 256 256" fill="currentColor"><g><path d="M176 96a48 48 0 1 1-48-48a48 48 0 0 1 48 48" opacity=".2"/><path d="M216 96a88 88 0 1 0-144 67.83V240a8 8 0 0 0 11.58 7.16L128 225l44.43 22.21a8.1 8.1 0 0 0 3.57.79a8 8 0 0 0 8-8v-76.17A87.85 87.85 0 0 0 216 96M56 96a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 131.06l-36.43-18.21a8 8 0 0 0-7.16 0L88 227.06v-52.69a87.89 87.89 0 0 0 80 0ZM128 152a56 56 0 1 0-56-56a56.06 56.06 0 0 0 56 56m0-96a40 40 0 1 1-40 40a40 40 0 0 1 40-40"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<div className={styles.head}>
<p className={styles.title}>{TITLE}</p>
<p className={styles.say} role="status" aria-live="polite">
{SAY_TEXT.replace("{d}", String(days)).replace("{n}", String(open))}
</p>
</div>
<div className={styles.row}>
{MARKS.map((m, i) => {
const on = days >= m;
return (
<button
className={[styles.badge, on ? styles.open : ""].filter(Boolean).join(" ")}
key={m}
type="button"
aria-pressed={on ? "true" : "false"}
aria-label={BADGE_LABEL.replace("{n}", String(m))}
style={badgeVars(i)}
>
<span className={styles.inner}>
<span className={`${styles.face} ${styles.faceLock}`}>
<b className={styles.mark}>{m}</b>
<span className={styles.lock}>{LOCK_TEXT}</span>
</span>
<span className={`${styles.face} ${styles.faceWin}`}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-medal" /></svg>
<b className={styles.mark}>{m}</b>
<span className={styles.shine} aria-hidden="true" />
</span>
</span>
</button>
);
})}
</div>
<div className={styles.bar}>
<button className={styles.go} type="button" onClick={() => bump(1)}>{PLUS1_TEXT}</button>
<button className={styles.go} type="button" onClick={() => bump(10)}>{PLUS10_TEXT}</button>
<button className={styles.reset} type="button" onClick={() => bump(-1)}>{RESET_TEXT}</button>
</div>
</div>
</main>
);
}08 · Badge unlock — Measured area 28.45% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
.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.
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-share-network" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 200a32 32 0 1 1-32-32a32 32 0 0 1 32 32M176 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32" opacity=".2"/><path d="M176 160a39.9 39.9 0 0 0-28.62 12.09l-46.1-29.63a39.8 39.8 0 0 0 0-28.92l46.1-29.63a40 40 0 1 0-8.66-13.45l-46.1 29.63a40 40 0 1 0 0 55.82l46.1 29.63A40 40 0 1 0 176 160m0-128a24 24 0 1 1-24 24a24 24 0 0 1 24-24M64 152a24 24 0 1 1 24-24a24 24 0 0 1-24 24m112 72a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="sh is-demo" id="sh">
<p class="sh__title">
<svg class="sh__ico" aria-hidden="true"><use href="#ico-share-network"/></svg>
<span>Today's card</span>
</p>
<div class="sh__main">
<span class="sh__cardBox">
<canvas class="sh__canvas" id="card" width="400" height="224" role="img" aria-label="Image of today's summary card"></canvas>
<span class="sh__toast" id="toast" aria-hidden="true">Copied</span>
</span>
<div class="sh__side">
<button class="sh__go" id="copy" type="button">Copy as image</button>
<button class="sh__more" id="more" type="button">Add one day</button>
<p class="sh__say" id="say" role="status" aria-live="polite">Card is ready</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 오늘 요약 공유 카드 — 카드는 DOM 이 아니라 canvas 에 그린다. 페이지가 열릴 때 한 번 그려 두므로
// 버튼을 누르기 전에도, 격자 미리보기에서도 무엇을 공유하는지 그대로 보인다.
// 복사는 canvas.toBlob → navigator.clipboard.write 이고, 막힌 환경에서는 toDataURL 링크로 내려받는다.
// 미리보기(.is-demo)는 카드가 한 번 떠올랐다 앉는 움직임과 알림 띠 컷을 겹친다 —
// 캔버스 그리기 자체는 렌더러의 애니메이션 시계로 움직이지 않으므로 움직임은 CSS 가 맡는다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.sh { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.sh__title {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(12px, 3.2vw, 14px); color: $color;
}
.sh__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.sh__main { display: flex; align-items: center; gap: $sp-4; }
.sh__cardBox { position: relative; display: block; flex: 0 0 auto; }
.sh__canvas {
display: block; width: 200px; height: 112px; border-radius: $r-card;
box-shadow: $shadow-raised;
}
.sh__toast {
position: absolute; left: 50%; bottom: $sp-2; transform: translate(-50%, 0);
border-radius: $r-pill; padding: $sp-1 $sp-3; background: $subject-cream; color: $color;
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.6; white-space: nowrap;
opacity: 0;
}
.sh__toast.is-on { opacity: 1; }
.sh__side { display: flex; flex-direction: column; align-items: flex-start; gap: $sp-2; flex: 1 1 auto; min-width: 0; }
.sh__go, .sh__more {
border-radius: $r-pill; padding: $sp-1 $sp-3; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.6;
}
.sh__go { border: 0; background: $color; color: $stage-yellow; }
.sh__more { border: 2px solid rgba($color, .5); background: transparent; color: $color; }
.sh__go:focus-visible, .sh__more:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sh__say { margin: 0; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($color, .68); }
// ── 격자 미리보기 ───────────────────────────────────────────────
.sh.is-demo .sh__canvas {
animation-name: shLift; animation-duration: $duration; animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes shLift {
0% { transform: translateY(0) scale(1); }
26% { transform: translateY(-14px) scale(1.04); }
62% { transform: translateY(-14px) scale(1.04); }
86% { transform: translateY(0) scale(1); }
100% { transform: translateY(0) scale(1); }
}
// 알림 띠는 반투명 겹침이 안 생기게 한 프레임에 컷으로 들어오고 나간다
.sh.is-demo .sh__toast {
animation-name: shToast; animation-duration: $duration; animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes shToast {
0%, 30% { opacity: 0; transform: translate(-50%, 8px); }
34% { opacity: 1; transform: translate(-50%, 0); }
72% { opacity: 1; transform: translate(-50%, 0); }
80% { opacity: 0; transform: translate(-50%, 8px); }
100% { opacity: 0; transform: translate(-50%, 8px); }
}
@media (max-width: 340px) {
.sh { gap: $sp-2; }
.sh__main { gap: $sp-2; }
.sh__canvas { width: 148px; height: 83px; }
.sh__side { gap: $sp-1; }
}
// reduce 에서는 떠오름과 알림 띠를 멈추고 카드 한 장만 보여 준다
@media (prefers-reduced-motion: reduce) {
.sh.is-demo .sh__canvas { animation: none !important; transform: none !important; }
.sh.is-demo .sh__toast { animation: none !important; opacity: 0 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sh {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.sh__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(12px, 3.2vw, 14px);
color: #17141a;
}
.sh__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.sh__main {
display: flex;
align-items: center;
gap: 16px;
}
.sh__cardBox {
position: relative;
display: block;
flex: 0 0 auto;
}
.sh__canvas {
display: block;
width: 200px;
height: 112px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sh__toast {
position: absolute;
left: 50%;
bottom: 8px;
transform: translate(-50%, 0);
border-radius: 999px;
padding: 4px 12px;
background: #fff7e6;
color: #17141a;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.6;
white-space: nowrap;
opacity: 0;
}
.sh__toast.is-on {
opacity: 1;
}
.sh__side {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
}
.sh__go, .sh__more {
border-radius: 999px;
padding: 4px 12px;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.6;
}
.sh__go {
border: 0;
background: #17141a;
color: #ffd23f;
}
.sh__more {
border: 2px solid rgba(23, 20, 26, 0.5);
background: transparent;
color: #17141a;
}
.sh__go:focus-visible, .sh__more:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sh__say {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.68);
}
.sh.is-demo .sh__canvas {
animation-name: shLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes shLift {
0% {
transform: translateY(0) scale(1);
}
26% {
transform: translateY(-14px) scale(1.04);
}
62% {
transform: translateY(-14px) scale(1.04);
}
86% {
transform: translateY(0) scale(1);
}
100% {
transform: translateY(0) scale(1);
}
}
.sh.is-demo .sh__toast {
animation-name: shToast;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes shToast {
0%, 30% {
opacity: 0;
transform: translate(-50%, 8px);
}
34% {
opacity: 1;
transform: translate(-50%, 0);
}
72% {
opacity: 1;
transform: translate(-50%, 0);
}
80% {
opacity: 0;
transform: translate(-50%, 8px);
}
100% {
opacity: 0;
transform: translate(-50%, 8px);
}
}
@media (max-width: 340px) {
.sh {
gap: 8px;
}
.sh__main {
gap: 8px;
}
.sh__canvas {
width: 148px;
height: 83px;
}
.sh__side {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.sh.is-demo .sh__canvas {
animation: none !important;
transform: none !important;
}
.sh.is-demo .sh__toast {
animation: none !important;
opacity: 0 !important;
}
}var STORE = 'godrich-habit-card';
var HEAD_TEXT = 'Habit summary';
var STREAK_TEXT = '{n}-day streak';
var DONE_TEXT = 'Done {c}/{t}';
var COPIED_TEXT = 'Copied';
var SAVED_TEXT = 'Saved as an image';
var MADE_TEXT = 'Card is ready';
var root = document.getElementById('sh');
var canvas = document.getElementById('card');
var ctx = canvas.getContext('2d');
var toast = document.getElementById('toast');
var sayEl = document.getElementById('say');
var state = { streak: 12, done: 5, total: 6 };
var toastTimer = 0;
function load() {
try {
var raw = window.localStorage.getItem(STORE);
if (raw) { state = JSON.parse(raw); }
} catch (e) { /* 저장소가 막힌 창에서는 기본값 그대로 */ }
}
function save() {
try { window.localStorage.setItem(STORE, JSON.stringify(state)); } catch (e) { /* 저장만 건너뛴다 */ }
}
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
// 카드는 페이지가 열릴 때 한 번 그려 둔다 — 버튼을 누르기 전에도 무엇을 공유하는지 보여야 한다
function draw() {
var d = new Date();
ctx.clearRect(0, 0, 400, 224);
ctx.fillStyle = '#17141a';
roundRect(0, 0, 400, 224, 24);
ctx.fill();
ctx.fillStyle = '#ffd23f';
roundRect(24, 24, 8, 40, 4);
ctx.fill();
ctx.fillStyle = '#fff7e6';
ctx.textBaseline = 'top';
ctx.font = '700 20px sans-serif';
ctx.fillText(HEAD_TEXT, 44, 30);
ctx.fillStyle = '#ffd23f';
ctx.font = '800 58px sans-serif';
ctx.fillText(STREAK_TEXT.replace('{n}', String(state.streak)), 40, 86);
ctx.fillStyle = '#fff7e6';
ctx.font = '700 20px sans-serif';
ctx.fillText(DONE_TEXT.replace('{c}', String(state.done)).replace('{t}', String(state.total)), 40, 164);
ctx.fillStyle = 'rgba(255, 247, 230, 0.55)';
ctx.font = '700 18px sans-serif';
ctx.fillText((d.getMonth() + 1) + '.' + d.getDate(), 320, 166);
}
function flash(text) {
sayEl.textContent = text;
toast.textContent = text;
toast.classList.add('is-on');
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(function () { toast.classList.remove('is-on'); }, 1400);
}
// 클립보드 이미지 쓰기가 막힌 환경(사파리 구버전·권한 거부)에서는 toDataURL 링크로 내려받는다
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);
}
load();
draw();
sayEl.textContent = MADE_TEXT;
document.getElementById('copy').addEventListener('click', function () {
root.classList.remove('is-demo');
copyCard();
});
document.getElementById('more').addEventListener('click', function () {
root.classList.remove('is-demo');
state.streak = state.streak + 1;
state.done = state.done >= state.total ? 1 : state.done + 1;
save();
draw();
flash(MADE_TEXT);
});// ShareCard — vanilla 판(run/297/demo/09-habit-share-card/index.html) 의 React 포트.
// 카드는 DOM 이 아니라 canvas 에 그린다. 값·수치·그리는 좌표는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShareCard.module.scss";
export type ShareCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type State = { streak: number; done: number; total: number };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const STORE = "godrich-habit-card";
const TITLE = "오늘 요약 카드";
const CANVAS_LABEL = "오늘 요약 카드 이미지";
const HEAD_TEXT = "오늘의 습관 요약";
const STREAK_TEXT = "{n}일 연속";
const DONE_TEXT = "완료 {c}/{t}";
const COPY_TEXT = "이미지로 복사";
const MORE_TEXT = "하루 더 채우기";
const COPIED_TEXT = "복사했습니다";
const SAVED_TEXT = "이미지로 내려받았습니다";
const MADE_TEXT = "카드를 만들었습니다";
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export default function ShareCard({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: ShareCardProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const timerRef = useRef<number>(0) as Box<number>;
const [state, setState] = useState<State>({ streak: 12, done: 5, total: 6 });
const [say, setSay] = useState(MADE_TEXT);
const [toast, setToast] = useState("");
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
try {
const raw = window.localStorage.getItem(STORE);
if (raw) { setState(JSON.parse(raw) as State); }
} catch (e) { /* 저장소가 막힌 창에서는 기본값 그대로 */ }
return () => { window.clearTimeout(timerRef.current); };
}, []);
// 카드는 상태가 바뀔 때마다 다시 그린다 — 버튼을 누르기 전에도 무엇을 공유하는지 보여야 한다
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) { return; }
const ctx = canvas.getContext("2d");
if (!ctx) { return; }
const d = new Date();
ctx.clearRect(0, 0, 400, 224);
ctx.fillStyle = "#17141a";
roundRect(ctx, 0, 0, 400, 224, 24);
ctx.fill();
ctx.fillStyle = "#ffd23f";
roundRect(ctx, 24, 24, 8, 40, 4);
ctx.fill();
ctx.fillStyle = "#fff7e6";
ctx.textBaseline = "top";
ctx.font = "700 20px sans-serif";
ctx.fillText(HEAD_TEXT, 44, 30);
ctx.fillStyle = "#ffd23f";
ctx.font = "800 58px sans-serif";
ctx.fillText(STREAK_TEXT.replace("{n}", String(state.streak)), 40, 86);
ctx.fillStyle = "#fff7e6";
ctx.font = "700 20px sans-serif";
ctx.fillText(DONE_TEXT.replace("{c}", String(state.done)).replace("{t}", String(state.total)), 40, 164);
ctx.fillStyle = "rgba(255, 247, 230, 0.55)";
ctx.font = "700 18px sans-serif";
ctx.fillText(`${d.getMonth() + 1}.${d.getDate()}`, 320, 166);
}, [state]);
function flash(text: string) {
setSay(text);
setToast(text);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => setToast(""), 1400);
}
// 클립보드 이미지 쓰기가 막힌 환경에서는 toDataURL 링크로 내려받는다
function download() {
const canvas = canvasRef.current;
if (!canvas) { return; }
const a = document.createElement("a");
a.href = canvas.toDataURL("image/png");
a.download = "habit-card.png";
a.click();
flash(SAVED_TEXT);
}
function copyCard() {
setDemo(false);
const canvas = canvasRef.current;
if (!canvas) { return; }
const holder = window as unknown as { ClipboardItem?: new (v: { [k: string]: Blob }) => ClipboardItem };
if (holder.ClipboardItem && navigator.clipboard && navigator.clipboard.write && canvas.toBlob) {
canvas.toBlob((blob) => {
if (!blob || !holder.ClipboardItem) { download(); return; }
const item = new holder.ClipboardItem({ "image/png": blob });
navigator.clipboard.write([item]).then(() => flash(COPIED_TEXT), download);
}, "image/png");
return;
}
download();
}
function addDay() {
setDemo(false);
setState({
streak: state.streak + 1,
done: state.done >= state.total ? 1 : state.done + 1,
total: state.total,
});
flash(MADE_TEXT);
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const toastCls = [styles.toast, toast ? styles.toastOn : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-share-network" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 200a32 32 0 1 1-32-32a32 32 0 0 1 32 32M176 88a32 32 0 1 0-32-32a32 32 0 0 0 32 32" opacity=".2"/><path d="M176 160a39.9 39.9 0 0 0-28.62 12.09l-46.1-29.63a39.8 39.8 0 0 0 0-28.92l46.1-29.63a40 40 0 1 0-8.66-13.45l-46.1 29.63a40 40 0 1 0 0 55.82l46.1 29.63A40 40 0 1 0 176 160m0-128a24 24 0 1 1-24 24a24 24 0 0 1 24-24M64 152a24 24 0 1 1 24-24a24 24 0 0 1-24 24m112 72a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.title}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-share-network" /></svg>
<span>{TITLE}</span>
</p>
<div className={styles.main}>
<span className={styles.cardBox}>
<canvas
className={styles.canvas}
ref={canvasRef}
width={400}
height={224}
role="img"
aria-label={CANVAS_LABEL}
/>
<span className={toastCls} aria-hidden="true">{toast}</span>
</span>
<div className={styles.side}>
<button className={styles.go} type="button" onClick={copyCard}>{COPY_TEXT}</button>
<button className={styles.more} type="button" onClick={addDay}>{MORE_TEXT}</button>
<p className={styles.say} role="status" aria-live="polite">{say}</p>
</div>
</div>
</div>
</main>
);
}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
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.









