04Calendar stamp
Press a cell in the month calendar and a stamp lands at an angle, while consecutive days get tied together by a band behind them. Stamped days are also marked by text inside the cell.
Best forA daily check-in event page, a monthly attendance sheet for a study group
role="grid"rotate:focus-visiblelocalStorage
<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 · Habit Tracker UI: 9 Parts from Grid to Badge


