08Badge unlock
The seven-, thirty- and hundred-day badges sit locked in gray until the count passes, then flip front to back and regain color. Locked state is written in text, not left to grayscale alone.
Best forAn achievement shelf, the reward screen after onboarding
rotateYbackface-visibilitytransform-stylegrayscale
<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 · Habit Tracker UI: 9 Parts from Grid to Badge


