08배지 잠금 해제
7일·30일·100일 배지가 회색으로 잠겨 있다가 조건을 넘으면 앞뒤로 뒤집히며 색을 찾습니다. 잠김 여부는 회색조뿐 아니라 자물쇠 글자로도 적습니다.
쓰임성취 배지 진열대, 온보딩 완료 보상 화면
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">성취 배지</p>
<p class="bu__say" id="say" role="status" aria-live="polite">지금 12일 · 배지 1개 열림</p>
</div>
<div class="bu__row" id="row"></div>
<div class="bu__bar">
<button class="bu__go" id="plus1" type="button">하루 더</button>
<button class="bu__go" id="plus10" type="button">열흘 더</button>
<button class="bu__reset" id="reset" type="button">처음으로</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}일 배지';
var LOCK_TEXT = '잠김';
var SAY_TEXT = '지금 {d}일 · 배지 {n}개 열림';
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 배지 잠금 해제 — 측정 누적면적 28.45% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 습관 트래커 UI 9종 — 잔디 격자부터 공유 카드까지


