05목표 진행률 스텝 카드
'다음 단계' 버튼을 누르면 막대가 실제로 그만큼 채워지고 방금 넘긴 단계에 체크 아이콘이 나타나며, 마지막 단계에서는 버튼이 사라집니다.
쓰임온보딩·구독 목표처럼 단계 4~5개짜리 진행률 하나만 카드로 보여주고 싶을 때
scaleXtransform-originclick
<main class="stage">
<div class="gt is-demo">
<div class="gt__top">
<span class="gt__ic">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M176 128a48 48 0 1 1-48-48a48 48 0 0 1 48 48" opacity=".2"/><path d="M221.87 83.16A104.1 104.1 0 1 1 195.67 49l22.67-22.68a8 8 0 0 1 11.32 11.32l-96 96a8 8 0 0 1-11.32-11.32l27.72-27.72a40 40 0 1 0 17.87 31.09a8 8 0 1 1 16-.9a56 56 0 1 1-22.38-41.65l22.75-22.75a87.88 87.88 0 1 0 23.13 29.67a8 8 0 0 1 14.44-6.9"/></g></svg>
</span>
<strong class="gt__title">구독 목표</strong>
<span class="gt__pct">1 / 4</span>
</div>
<div class="gt__track"><div class="gt__fill"></div></div>
<ol class="gt__steps">
<li class="gt__step" data-p="25">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><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 0"/></g></svg></span></span>
<span class="gt__label">가입</span>
</li>
<li class="gt__step" data-p="50">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><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 0"/></g></svg></span></span>
<span class="gt__label">첫 결제</span>
</li>
<li class="gt__step" data-p="75">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><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 0"/></g></svg></span></span>
<span class="gt__label">연동</span>
</li>
<li class="gt__step" data-p="100">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><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 0"/></g></svg></span></span>
<span class="gt__label">완료</span>
</li>
</ol>
<button class="gt__next" type="button">다음 단계</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-paper);
.gt {
box-sizing: border-box;
width: min(240px, 86vw);
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
padding: $sp-4;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.gt__top { display: flex; align-items: center; gap: $sp-1; margin: 0 0 $sp-3; }
.gt__ic { flex: 0 0 auto; color: $color; }
.gt__title { flex: 1 1 auto; font-size: 12px; }
.gt__pct { font-size: 11px; font-weight: 800; color: $subject-ink; }
.gt__track {
height: 6px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
margin: 0 0 $sp-4;
}
.gt__fill {
width: 100%; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 진행바 왼쪽 채움
transform: scaleX(0);
transition: transform $duration $easing;
}
.gt__steps { list-style: none; display: flex; justify-content: space-between; margin: 0 0 $sp-4; padding: 0; }
.gt__step { display: flex; flex-direction: column; align-items: center; gap: $sp-1; flex: 1; }
.gt__dot {
position: relative;
width: 18px; height: 18px;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px rgba(23, 20, 26, .18);
display: grid; place-items: center;
}
.gt__check {
position: absolute; inset: 0;
border-radius: 50%;
background: $color;
color: #fff;
display: grid; place-items: center;
opacity: 0;
transform: scale(.6);
transition: opacity $duration $easing, transform $duration $easing;
}
.gt__step.is-active .gt__check { opacity: 1; transform: scale(1); }
.gt__label { font-size: 9px; color: rgba(23, 20, 26, .72); text-align: center; }
.gt__step.is-active .gt__label { color: $stage-ink; font-weight: 700; }
.gt__next {
width: 100%;
border: 0;
background: $color;
color: $subject-ink;
font: inherit; font-weight: 700; font-size: 12px;
padding: $sp-2 $sp-2;
border-radius: $r-control;
cursor: pointer;
}
// ── 자동 루프: 막대가 0→100 을 반복하고, 단계 체크가 순서대로 켜진다 ──────────────
.gt.is-demo .gt__fill { animation: gt-fill 3s $easing infinite; transform-origin: left center; }
@keyframes gt-fill {
0% { transform: scaleX(0); }
85% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
.gt.is-demo .gt__step .gt__check {
animation: gt-check 3s $easing infinite;
}
.gt.is-demo .gt__step:nth-child(1) .gt__check { animation-delay: 0s; }
.gt.is-demo .gt__step:nth-child(2) .gt__check { animation-delay: .55s; }
.gt.is-demo .gt__step:nth-child(3) .gt__check { animation-delay: 1.1s; }
.gt.is-demo .gt__step:nth-child(4) .gt__check { animation-delay: 1.65s; }
@keyframes gt-check {
0%, 15% { opacity: 0; transform: scale(.6); }
30% { opacity: 1; transform: scale(1); }
100% { opacity: 1; transform: scale(1); }
}
@media (max-width: 340px) {
.gt { padding: $sp-3; }
.gt__label { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__fill { animation: none !important; }
.gt.is-demo .gt__step .gt__check { animation: none !important; }
.gt__fill, .gt__check { transition: none; }
}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);
}
.gt {
box-sizing: border-box;
width: min(240px, 86vw);
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.gt__top {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 12px;
}
.gt__ic {
flex: 0 0 auto;
color: #4cd4a6;
}
.gt__title {
flex: 1 1 auto;
font-size: 12px;
}
.gt__pct {
font-size: 11px;
font-weight: 800;
color: #17141a;
}
.gt__track {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
margin: 0 0 16px;
}
.gt__fill {
width: 100%;
height: 100%;
background: #4cd4a6;
transform-origin: left center;
transform: scaleX(0);
transition: transform 300ms ease-out;
}
.gt__steps {
list-style: none;
display: flex;
justify-content: space-between;
margin: 0 0 16px;
padding: 0;
}
.gt__step {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
}
.gt__dot {
position: relative;
width: 18px;
height: 18px;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px rgba(23, 20, 26, 0.18);
display: grid;
place-items: center;
}
.gt__check {
position: absolute;
inset: 0;
border-radius: 50%;
background: #4cd4a6;
color: #fff;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.gt__step.is-active .gt__check {
opacity: 1;
transform: scale(1);
}
.gt__label {
font-size: 9px;
color: rgba(23, 20, 26, 0.72);
text-align: center;
}
.gt__step.is-active .gt__label {
color: #17141a;
font-weight: 700;
}
.gt__next {
width: 100%;
border: 0;
background: #4cd4a6;
color: #17141a;
font: inherit;
font-weight: 700;
font-size: 12px;
padding: 8px 8px;
border-radius: 12px;
cursor: pointer;
}
.gt.is-demo .gt__fill {
animation: gt-fill 3s ease-out infinite;
transform-origin: left center;
}
@keyframes gt-fill {
0% {
transform: scaleX(0);
}
85% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.gt.is-demo .gt__step .gt__check {
animation: gt-check 3s ease-out infinite;
}
.gt.is-demo .gt__step:nth-child(1) .gt__check {
animation-delay: 0s;
}
.gt.is-demo .gt__step:nth-child(2) .gt__check {
animation-delay: 0.55s;
}
.gt.is-demo .gt__step:nth-child(3) .gt__check {
animation-delay: 1.1s;
}
.gt.is-demo .gt__step:nth-child(4) .gt__check {
animation-delay: 1.65s;
}
@keyframes gt-check {
0%, 15% {
opacity: 0;
transform: scale(0.6);
}
30% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.gt {
padding: 12px;
}
.gt__label {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__fill {
animation: none !important;
}
.gt.is-demo .gt__step .gt__check {
animation: none !important;
}
.gt__fill, .gt__check {
transition: none;
}
}// 실제 동작: '다음 단계'를 누르면 막대가 실제로 그 단계 값까지 채워지고 지나온 단계에 체크가 남는다.
var steps = document.querySelectorAll('.gt__step');
var fill = document.querySelector('.gt__fill');
var pct = document.querySelector('.gt__pct');
var btn = document.querySelector('.gt__next');
var idx = 0;
function apply() {
steps.forEach(function (s, i) { s.classList.toggle('is-active', i <= idx); });
fill.style.transform = 'scaleX(' + (Number(steps[idx].dataset.p) / 100) + ')';
pct.textContent = (idx + 1) + ' / ' + steps.length;
btn.hidden = idx === steps.length - 1;
}
btn.addEventListener('click', function () {
document.querySelector('.gt').classList.remove('is-demo');
idx = Math.min(idx + 1, steps.length - 1);
apply();
});
apply();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GoalTracker.module.scss";
export type GoalTrackerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STEPS = [
{ label: "Sign up", p: 25 },
{ label: "First payment", p: 50 },
{ label: "Integration", p: 75 },
{ label: "Done", p: 100 },
];
export default function GoalTracker({ duration = 300, easing = "ease-out", color = "#4cd4a6" }: GoalTrackerProps) {
const [idx, setIdx] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.gt} style={vars}>
<div className={styles.top}>
<span className={styles.ic}>◎</span>
<strong className={styles.title}>Subscription goal</strong>
<span className={styles.pct}>{idx + 1} / {STEPS.length}</span>
</div>
<div className={styles.track}>
<div className={styles.fill} style={{ transform: `scaleX(${STEPS[idx].p / 100})` }} />
</div>
<ol className={styles.steps}>
{STEPS.map((s, i) => (
<li key={s.label} className={styles.step}>
<span className={styles.dot}>
<span className={`${styles.check} ${i <= idx ? styles.active : ""}`}>✓</span>
</span>
<span className={`${styles.label} ${i <= idx ? styles.activeLabel : ""}`}>{s.label}</span>
</li>
))}
</ol>
{idx < STEPS.length - 1 && (
<button type="button" className={styles.next} onClick={() => setIdx((i) => Math.min(i + 1, STEPS.length - 1))}>
Next step
</button>
)}
</div>
);
}05 목표 진행률 스텝 카드 — 측정 누적면적 1.372% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 대시보드 컴포넌트 9종 — 화면 통째로 말고 이 부품만
이 데모가 속한 글
대시보드 컴포넌트 9종 — 화면 통째로 말고 이 부품만대시보드 컴포넌트는 완성된 관리자 화면을 통째로 붙이지 않고 헤더·피드·캘린더처럼 화면 조각 하나만 그대로 옮겨 쓰는 UI 부품입니다. 아홉 개 중 하나는 Ctrl(⌘)+K로 여는 커맨드 팔레트인데, 방향키로 실제로 이 글의 데모 전부 보기


