02연속 일수 불꽃
연속 일수가 오를 때마다 불꽃이 제 중심에서 한 단계씩 커지고, 끊으면 한 프레임 만에 꺼진 뒤 최장 기록만 남습니다. 숫자가 바뀐 사실은 aria-live 로도 읽힙니다.
쓰임학습 앱 상단의 연속 출석 배너, 운동 기록 앱 홈 화면
transform-originsteps(1, end)aria-livelocalStorage
<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">일 연속</span>
<span class="sc__best" id="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일 연속입니다</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>오늘 체크</span>
</button>
<button class="sc__cut" id="cut" type="button">연속 끊기</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}일 연속입니다';
var SAY_OFF = '연속이 끊겼습니다. 최장 기록은 {b}일';
var BEST_TEXT = '최장 {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 연속 일수 불꽃 — 측정 누적면적 4.381% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 습관 트래커 UI 9종 — 잔디 격자부터 공유 카드까지


