06Stepper thanh toán
Trong bốn bước giỏ hàng, giao hàng, thanh toán, hoàn tất: bước nào xong thì số trong vòng tròn đổi thành icon check SVG được vẽ ra khi stroke-dashoffset thu dần, còn đường nối sang bước kế tiếp được tô bằng scaleX.
Dùng choLuồng thanh toán thương mại điện tử, form đặt chỗ nhiều bước
stroke-dashoffsetscaleXSVG

<main class="stage">
<div class="ts06-steps is-demo">
<div class="ts06-step">
<span class="ts06-circle" aria-label="Hoàn tất">
<svg class="ts06-check" viewBox="0 0 16 16" fill="none" aria-hidden="true"><polyline points="3,8 7,12 13,4" /></svg>
<span class="ts06-num">1</span>
</span>
<span class="ts06-label">Giỏ hàng</span>
</div>
<span class="ts06-bar"><span class="ts06-fill"></span></span>
<div class="ts06-step">
<span class="ts06-circle" aria-label="Hoàn tất">
<svg class="ts06-check" viewBox="0 0 16 16" fill="none" aria-hidden="true"><polyline points="3,8 7,12 13,4" /></svg>
<span class="ts06-num">2</span>
</span>
<span class="ts06-label">Giao hàng</span>
</div>
<span class="ts06-bar"><span class="ts06-fill"></span></span>
<div class="ts06-step ts06-step--active">
<span class="ts06-circle"><span class="ts06-num">3</span></span>
<span class="ts06-label">Thanh toán</span>
</div>
<span class="ts06-bar"></span>
<div class="ts06-step">
<span class="ts06-circle"><span class="ts06-num">4</span></span>
<span class="ts06-label">Hoàn tất</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-yellow);
.ts06-steps {
display: flex;
align-items: flex-start;
width: min(340px, 90vw);
}
.ts06-step {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
color: $color;
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts06-circle {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(23, 20, 26, .12);
display: grid;
place-items: center;
}
.ts06-num { font-weight: 700; font-size: 13px; line-height: 1; font-family: inherit; }
// 체크 아이콘: SVG polyline 을 stroke-dashoffset 으로 그린다 (BD16: 막대 두 개 사선 대신 안전한 방법)
.ts06-check {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
opacity: 0;
polyline {
fill: none;
stroke: $subject-cream;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
}
.ts06-num { opacity: 0; }
// 실제 사용 코드: 끝난 단계는 JS 가 .is-checked 를 붙이면 체크가 그려지고 숫자는 사라진다
.ts06-step.is-checked .ts06-circle { background: $color; }
.ts06-step.is-checked .ts06-check { opacity: 1; }
.ts06-step.is-checked .ts06-check polyline { animation: ts06-draw .5s $easing forwards; }
.ts06-step.is-checked .ts06-num { display: none; }
.ts06-step:not(.is-checked) .ts06-num { opacity: 1; }
.ts06-step--active .ts06-circle { background: $color; color: $subject-cream; }
.ts06-step--active .ts06-num { opacity: 1; color: $subject-cream; }
.ts06-step.is-checked + .ts06-bar .ts06-fill { transform: scaleX(1); }
.ts06-bar {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
height: 3px;
margin-top: 16px;
background: rgba(23, 20, 26, .14);
border-radius: $r-xs;
overflow: hidden;
display: block;
}
.ts06-fill {
display: block;
width: 100%;
height: 100%;
background: $color;
transform: scaleX(0);
transform-origin: left; // 규칙8: 체크아웃 연결선 좌측 채움
}
@keyframes ts06-draw { from { stroke-dashoffset: 16; } to { stroke-dashoffset: 0; } }
// 데모 루프: 1·2 는 이미 완료, 3(결제)이 활성 상태로 펄스
.ts06-steps.is-demo .ts06-step--active .ts06-circle { animation: ts06-active-pulse 1.2s $easing infinite; }
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-circle,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-circle { background: $color; }
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-check,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-check { opacity: 1; }
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-num,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-num { display: none; }
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-check polyline { animation: ts06-draw-loop $duration $easing infinite; }
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-check polyline { animation: ts06-draw-loop $duration $easing infinite; animation-delay: .35s; }
.ts06-steps.is-demo .ts06-bar:nth-child(2) .ts06-fill { animation: ts06-fill-loop $duration $easing infinite; }
.ts06-steps.is-demo .ts06-bar:nth-child(4) .ts06-fill { animation: ts06-fill-loop $duration $easing infinite; animation-delay: .35s; }
@keyframes ts06-draw-loop {
0%, 5% { stroke-dashoffset: 16; }
35%, 100% { stroke-dashoffset: 0; }
}
@keyframes ts06-fill-loop {
0%, 8% { transform: scaleX(0); }
40%, 100% { transform: scaleX(1); }
}
@keyframes ts06-active-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(23, 20, 26, .35); }
50% { box-shadow: 0 0 0 6px rgba(23, 20, 26, 0); }
}
@media (prefers-reduced-motion: reduce) {
.ts06-check polyline, .ts06-steps.is-demo .ts06-check polyline { animation: none !important; stroke-dashoffset: 0 !important; }
.ts06-fill, .ts06-steps.is-demo .ts06-fill { animation: none !important; }
.ts06-steps.is-demo .ts06-step--active .ts06-circle { 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);
}
.ts06-steps {
display: flex;
align-items: flex-start;
width: min(340px, 90vw);
}
.ts06-step {
flex-grow: 0;
flex-shrink: 0;
flex-basis: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #17141a;
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.ts06-circle {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.12);
display: grid;
place-items: center;
}
.ts06-num {
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
}
.ts06-check {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 8px;
box-sizing: border-box;
opacity: 0;
}
.ts06-check polyline {
fill: none;
stroke: #fff7e6;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 16;
stroke-dashoffset: 16;
}
.ts06-num {
opacity: 0;
}
.ts06-step.is-checked .ts06-circle {
background: #17141a;
}
.ts06-step.is-checked .ts06-check {
opacity: 1;
}
.ts06-step.is-checked .ts06-check polyline {
animation: ts06-draw 0.5s ease-out forwards;
}
.ts06-step.is-checked .ts06-num {
display: none;
}
.ts06-step:not(.is-checked) .ts06-num {
opacity: 1;
}
.ts06-step--active .ts06-circle {
background: #17141a;
color: #fff7e6;
}
.ts06-step--active .ts06-num {
opacity: 1;
color: #fff7e6;
}
.ts06-step.is-checked + .ts06-bar .ts06-fill {
transform: scaleX(1);
}
.ts06-bar {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 0;
height: 3px;
margin-top: 16px;
background: rgba(23, 20, 26, 0.14);
border-radius: 4px;
overflow: hidden;
display: block;
}
.ts06-fill {
display: block;
width: 100%;
height: 100%;
background: #17141a;
transform: scaleX(0);
transform-origin: left;
}
@keyframes ts06-draw {
from {
stroke-dashoffset: 16;
}
to {
stroke-dashoffset: 0;
}
}
.ts06-steps.is-demo .ts06-step--active .ts06-circle {
animation: ts06-active-pulse 1.2s ease-out infinite;
}
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-circle,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-circle {
background: #17141a;
}
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-check,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-check {
opacity: 1;
}
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-num,
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-num {
display: none;
}
.ts06-steps.is-demo .ts06-step:nth-child(1) .ts06-check polyline {
animation: ts06-draw-loop 2s ease-out infinite;
}
.ts06-steps.is-demo .ts06-step:nth-child(3) .ts06-check polyline {
animation: ts06-draw-loop 2s ease-out infinite;
animation-delay: 0.35s;
}
.ts06-steps.is-demo .ts06-bar:nth-child(2) .ts06-fill {
animation: ts06-fill-loop 2s ease-out infinite;
}
.ts06-steps.is-demo .ts06-bar:nth-child(4) .ts06-fill {
animation: ts06-fill-loop 2s ease-out infinite;
animation-delay: 0.35s;
}
@keyframes ts06-draw-loop {
0%, 5% {
stroke-dashoffset: 16;
}
35%, 100% {
stroke-dashoffset: 0;
}
}
@keyframes ts06-fill-loop {
0%, 8% {
transform: scaleX(0);
}
40%, 100% {
transform: scaleX(1);
}
}
@keyframes ts06-active-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(23, 20, 26, 0.35);
}
50% {
box-shadow: 0 0 0 6px rgba(23, 20, 26, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.ts06-check polyline, .ts06-steps.is-demo .ts06-check polyline {
animation: none !important;
stroke-dashoffset: 0 !important;
}
.ts06-fill, .ts06-steps.is-demo .ts06-fill {
animation: none !important;
}
.ts06-steps.is-demo .ts06-step--active .ts06-circle {
animation: none !important;
}
}import { CSSProperties } from "react";
import styles from "./CheckoutStepper.module.scss";
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
export interface CheckoutStepperProps {
duration?: string;
easing?: string;
color?: string;
labels?: string[];
activeIndex?: number;
}
export default function CheckoutStepper({
duration = "500ms",
easing = "ease-out",
color = "#17141a",
labels = ["장바구니", "배송", "결제", "완료"],
activeIndex = 2,
}: CheckoutStepperProps) {
const style: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<div className={styles.steps} style={style}>
{labels.map((label, i) => (
<div key={label} style={{ display: "contents" }}>
<div className={styles.step}>
<span className={`${styles.circle} ${i < activeIndex ? styles.checked : ""} ${i === activeIndex ? styles.active : ""}`} aria-label={i < activeIndex ? "완료" : undefined}>
{i < activeIndex ? (
<svg className={styles.check} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<polyline points="3,8 7,12 13,4" />
</svg>
) : (
<span className={styles.num}>{i + 1}</span>
)}
</span>
<span>{label}</span>
</div>
{i < labels.length - 1 && (
<span className={styles.bar}>
<span className={`${styles.fill} ${i < activeIndex - 1 ? styles.filled : ""}`} />
</span>
)}
</div>
))}
</div>
);
}06 Stepper thanh toán — Diện tích đo được 0.77% · khung hình chuyển động 20/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 css timeline animation không JS, copy-paste
Bài viết chứa demo này
9 css timeline animation không JS, copy-pasteCss timeline animation là cách nối nhiều bước bằng đường kẻ với vòng tròn để bày ra theo thứ tự. Chín mẫu dưới đây không Xem tất cả demo trong bài này


