01ウェルカムモーダルのステッパー
登録直後に出るウェルカムモーダルが下から18px浮き上がって開き、上部の3つのドットが1つずつ進むたびに現在のドットだけが横に伸びて錠剤の形になります。
使いどころSaaS登録直後の最初の画面、アプリ初回起動時の案内
translateY 18px점→알약radius 16

<main class="stage is-demo">
<div class="modal" role="dialog" aria-label="ようこそ案内">
<span class="ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M184,213.27A80,80,0,0,1,74.7,184l-40-69.32a20,20,0,0,1,34.64-20L55.08,70A20,20,0,0,1,89.73,50l6.92,12h0a20,20,0,0,1,34.64-20l30,52A20,20,0,0,1,196,74l17.31,30A80,80,0,0,1,184,213.27Z" opacity="0.2"/><path d="M220.17,100,202.86,70a28,28,0,0,0-38.24-10.25,27.69,27.69,0,0,0-9,8.34L138.2,38a28,28,0,0,0-48.48,0A28,28,0,0,0,48.15,74l1.59,2.76A27.67,27.67,0,0,0,38,80.41a28,28,0,0,0-10.24,38.25l40,69.32a87.47,87.47,0,0,0,53.43,41,88.56,88.56,0,0,0,22.92,3,88,88,0,0,0,76.06-132Zm-6.66,62.64A72,72,0,0,1,81.62,180l-40-69.32a12,12,0,0,1,20.78-12L81.63,132a8,8,0,1,0,13.85-8L62,66A12,12,0,1,1,82.78,54L114,108a8,8,0,1,0,13.85-8L103.57,58h0a12,12,0,1,1,20.78-12l33.42,57.9a48,48,0,0,0-5.54,60.6,8,8,0,0,0,13.24-9A32,32,0,0,1,172.78,112a8,8,0,0,0,2.13-10.4L168.23,90A12,12,0,1,1,189,78l17.31,30A71.56,71.56,0,0,1,213.51,162.62ZM184.25,31.71A8,8,0,0,1,194,26a59.62,59.62,0,0,1,36.53,28l.33.57a8,8,0,1,1-13.85,8l-.33-.57a43.67,43.67,0,0,0-26.8-20.5A8,8,0,0,1,184.25,31.71ZM80.89,237a8,8,0,0,1-11.23,1.33A119.56,119.56,0,0,1,40.06,204a8,8,0,0,1,13.86-8,103.67,103.67,0,0,0,25.64,29.72A8,8,0,0,1,80.89,237Z"/></svg></span>
<p class="t">ようこそ</p>
<p class="s">3ステップで開始</p>
<div class="dots" aria-hidden="true"><i class="d1"></i><i class="d2"></i><i class="d3"></i></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 모달 등장 1회 + 점 세 칸 순회 1바퀴
$easing: $ease-spring;
$color: $subject-blue; // 브랜드 색 하나 (밝은-신뢰 톤)
@include stage(#eef3fb);
.modal {
box-sizing: border-box;
width: min(100%, 232px);
padding: $sp-4;
border-radius: $r-card;
background: #fff;
border: 1px solid #dbe4f0;
box-shadow: 0 10px 24px rgba(23, 20, 26, .10);
text-align: center;
// 시작 자세 — 아래에서 18px. 애니메이션이 꺼져도 제자리로 보이게 translate 0 이 기본
transform: translateY(0);
}
.stage.is-demo .modal { animation: rise $duration $easing infinite; }
.ico {
display: inline-flex;
width: 28px; height: 28px;
color: $color;
rotate: 0deg; // 개별 속성 중립 자세 — 3차 진자가 꺼져도 기울지 않는다
}
.ico svg { width: 100%; height: 100%; display: block; }
.t {
margin: $sp-2 0 0;
font-size: 15px; font-weight: 700;
color: $subject-ink; // 본문 잉크 — 파스텔 바탕에서 대비를 버는 유일한 길
}
.s {
margin: $sp-1 0 0;
font-size: 12px;
color: #475569;
}
.dots {
display: flex; justify-content: center; align-items: center;
gap: $sp-2;
margin: $sp-4 0 0;
}
.dots i {
display: block;
position: relative; // 잔상 ::after 의 좌표 기준 — 오프셋 없으니 레이아웃 불변
width: 20px; height: 8px;
border-radius: $r-pill;
background: #cbd5e1;
// 기본은 점(20px 의 40% = 8px). 활성 단계만 가로로 펴져 알약이 된다
transform: scaleX(.4);
}
// ⛔ 여기서 `animation:` 단축 속성을 쓰면 아래 .d2·.d3 의 animation-delay 가 같이 리셋된다
// (단축 속성은 안 적은 하위 속성을 초기값으로 되돌린다 — 실측 2026-09-11: 세 점이 동시에 켜졌다).
// 시차를 다른 규칙에 둘 땐 반드시 낱개 속성으로 쓴다
.stage.is-demo .dots i {
animation-name: step, dot-pop; // 3차: steps(1) 점 전환 위에 탄성 lerp 를 곱합성 (scale:)
animation-duration: $duration;
animation-timing-function: steps(1, end), $ease-lerp-settle;
animation-iteration-count: infinite;
}
.stage.is-demo .d2 { animation-delay: calc(#{$duration} / 3); }
.stage.is-demo .d3 { animation-delay: calc(#{$duration} / 3 * 2); }
// 한 겹(도트 잔상 + 아이콘 감쇠 진자) — 2026-09-15 형태 2차
// 잔상: dot 이 알약→점 으로 되돌아올 때 같은 keyframes 을 120ms 늦게 따라가는
// 옅은 복제. steps(1) 원본 시맨틱은 그대로 두고 뒤에 깔기만 한다
.dots i::after {
content: "";
position: absolute; inset: 0;
border-radius: $r-pill;
background: $color;
opacity: .35;
transform: scaleX(.4);
}
.stage.is-demo .dots i::after {
animation-name: step;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.stage.is-demo .d2::after { animation-delay: calc(#{$duration} / 3); }
.stage.is-demo .d3::after { animation-delay: calc(#{$duration} / 3 * 2); }
// 아이콘 감쇠 진자 — 환영 손짓. transform-origin 을 위 꼭지로 두고
// 회전 감쇠 진폭(-14° → 10° → -6° → 3°)이 갈수록 작아진다
.stage.is-demo .ico {
animation: ico-pendulum $duration $ease-lerp-settle infinite,
ico-swing $duration $ease-lerp-settle infinite;
transform-origin: 50% 0%;
rotate: 0deg;
}
@keyframes ico-pendulum {
0%, 6% { transform: rotate(0); }
18% { transform: rotate(-14deg); }
38% { transform: rotate(10deg); }
56% { transform: rotate(-6deg); }
74% { transform: rotate(3deg); }
88%,100%{ transform: rotate(0); }
}
// 한 겹 3차 — 개별 변환 속성(rotate:)의 위상 어긋난 미세 진자.
// 2차 진자(transform)와 감쇠 비율은 같지만 극값 시점을 뒤로 민 하모닉이라
// 두 회전이 곱해지며 손짓이 살아 있는 구간이 길어진다
@keyframes ico-swing {
0% { rotate: 0deg; }
14% { rotate: 5deg; }
36% { rotate: -3.1deg; }
56% { rotate: 1.75deg; }
76% { rotate: -.8deg; }
90%, 100% { rotate: 0deg; }
}
@keyframes rise {
0% { transform: translateY(18px); opacity: 0; }
18% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(0); opacity: 1; }
}
// 한 칸당 1/3 주기만 활성. steps(1) 로 딱딱 끊어 "지금 몇 번째" 가 분명해진다
@keyframes step {
0% { transform: scaleX(1); background: $color; }
33.33% { transform: scaleX(.4); background: #cbd5e1; }
100% { transform: scaleX(.4); background: #cbd5e1; }
}
// 3차: 활성 전환 순간의 탄성 lerp — 개별 속성(scale:)이라 step 의 scaleX 와
// 곱합성된다 (1.18 피크 = 알약이 잠깐 부풀었다 정렬). 이웃 점과 갭(8px) 안에서
// 겹치지 않는 상한
@keyframes dot-pop {
0% { scale: 1; }
5% { scale: 1.18; }
13% { scale: .93; }
21% { scale: 1.04; }
29%, 100% { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .modal { animation: none; transform: none; opacity: 1; }
.stage.is-demo .dots i, .stage.is-demo .dots i::after { animation: none; }
.stage.is-demo .ico { animation: none; rotate: 0deg; }
.stage.is-demo .d1 { transform: scaleX(1); background: $color; }
.stage.is-demo .dots i::after { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #eef3fb;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.modal {
box-sizing: border-box;
width: min(100%, 232px);
padding: 16px;
border-radius: 16px;
background: #fff;
border: 1px solid #dbe4f0;
box-shadow: 0 10px 24px rgba(23, 20, 26, 0.1);
text-align: center;
transform: translateY(0);
}
.stage.is-demo .modal {
animation: rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ico {
display: inline-flex;
width: 28px;
height: 28px;
color: #2f6df6;
rotate: 0deg;
}
.ico svg {
width: 100%;
height: 100%;
display: block;
}
.t {
margin: 8px 0 0;
font-size: 15px;
font-weight: 700;
color: #17141a;
}
.s {
margin: 4px 0 0;
font-size: 12px;
color: #475569;
}
.dots {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin: 16px 0 0;
}
.dots i {
display: block;
position: relative;
width: 20px;
height: 8px;
border-radius: 999px;
background: #cbd5e1;
transform: scaleX(0.4);
}
.stage.is-demo .dots i {
animation-name: step, dot-pop;
animation-duration: 2s;
animation-timing-function: steps(1, end), linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1);
animation-iteration-count: infinite;
}
.stage.is-demo .d2 {
animation-delay: calc(2s / 3);
}
.stage.is-demo .d3 {
animation-delay: calc(2s / 3 * 2);
}
.dots i::after {
content: "";
position: absolute;
inset: 0;
border-radius: 999px;
background: #2f6df6;
opacity: 0.35;
transform: scaleX(0.4);
}
.stage.is-demo .dots i::after {
animation-name: step;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.stage.is-demo .d2::after {
animation-delay: calc(2s / 3);
}
.stage.is-demo .d3::after {
animation-delay: calc(2s / 3 * 2);
}
.stage.is-demo .ico {
animation: ico-pendulum 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite, ico-swing 2s linear(0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1) infinite;
transform-origin: 50% 0%;
rotate: 0deg;
}
@keyframes ico-pendulum {
0%, 6% {
transform: rotate(0);
}
18% {
transform: rotate(-14deg);
}
38% {
transform: rotate(10deg);
}
56% {
transform: rotate(-6deg);
}
74% {
transform: rotate(3deg);
}
88%, 100% {
transform: rotate(0);
}
}
@keyframes ico-swing {
0% {
rotate: 0deg;
}
14% {
rotate: 5deg;
}
36% {
rotate: -3.1deg;
}
56% {
rotate: 1.75deg;
}
76% {
rotate: -0.8deg;
}
90%, 100% {
rotate: 0deg;
}
}
@keyframes rise {
0% {
transform: translateY(18px);
opacity: 0;
}
18% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes step {
0% {
transform: scaleX(1);
background: #2f6df6;
}
33.33% {
transform: scaleX(0.4);
background: #cbd5e1;
}
100% {
transform: scaleX(0.4);
background: #cbd5e1;
}
}
@keyframes dot-pop {
0% {
scale: 1;
}
5% {
scale: 1.18;
}
13% {
scale: 0.93;
}
21% {
scale: 1.04;
}
29%, 100% {
scale: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .modal {
animation: none;
transform: none;
opacity: 1;
}
.stage.is-demo .dots i, .stage.is-demo .dots i::after {
animation: none;
}
.stage.is-demo .ico {
animation: none;
rotate: 0deg;
}
.stage.is-demo .d1 {
transform: scaleX(1);
background: #2f6df6;
}
.stage.is-demo .dots i::after {
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./WelcomeModalStepper.module.scss";
export type WelcomeModalStepperProps = {
duration?: number;
easing?: string;
color?: string;
title?: string;
subtitle?: string;
steps?: number;
};
export default function WelcomeModalStepper({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
title = "Welcome aboard",
subtitle = "Start in 3 steps",
steps = 3,
}: WelcomeModalStepperProps) {
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
} as CSSProperties;
return (
<div className={styles.modal} style={vars} role="dialog" aria-label="Welcome dialog">
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
<path d="M184,213.27A80,80,0,0,1,74.7,184l-40-69.32a20,20,0,0,1,34.64-20L55.08,70A20,20,0,0,1,89.73,50l6.92,12h0a20,20,0,0,1,34.64-20l30,52A20,20,0,0,1,196,74l17.31,30A80,80,0,0,1,184,213.27Z" opacity="0.2" />
<path d="M220.17,100,202.86,70a28,28,0,0,0-38.24-10.25,27.69,27.69,0,0,0-9,8.34L138.2,38a28,28,0,0,0-48.48,0A28,28,0,0,0,48.15,74l1.59,2.76A27.67,27.67,0,0,0,38,80.41a28,28,0,0,0-10.24,38.25l40,69.32a87.47,87.47,0,0,0,53.43,41,88.56,88.56,0,0,0,22.92,3,88,88,0,0,0,76.06-132Zm-6.66,62.64A72,72,0,0,1,81.62,180l-40-69.32a12,12,0,0,1,20.78-12L81.63,132a8,8,0,1,0,13.85-8L62,66A12,12,0,1,1,82.78,54L114,108a8,8,0,1,0,13.85-8L103.57,58h0a12,12,0,1,1,20.78-12l33.42,57.9a48,48,0,0,0-5.54,60.6,8,8,0,0,0,13.24-9A32,32,0,0,1,172.78,112a8,8,0,0,0,2.13-10.4L168.23,90A12,12,0,1,1,189,78l17.31,30A71.56,71.56,0,0,1,213.51,162.62ZM184.25,31.71A8,8,0,0,1,194,26a59.62,59.62,0,0,1,36.53,28l.33.57a8,8,0,1,1-13.85,8l-.33-.57a43.67,43.67,0,0,0-26.8-20.5A8,8,0,0,1,184.25,31.71ZM80.89,237a8,8,0,0,1-11.23,1.33A119.56,119.56,0,0,1,40.06,204a8,8,0,0,1,13.86-8,103.67,103.67,0,0,0,25.64,29.72A8,8,0,0,1,80.89,237Z" />
</svg>
</span>
<p className={styles.t}>{title}</p>
<p className={styles.s}>{subtitle}</p>
<div className={styles.dots} aria-hidden="true">
{Array.from({ length: steps }, (_, i) => (
// 시차는 인라인 변수로. className 에 animation 단축 속성을 다시 쓰면 이 지연이 리셋된다
<i key={i} style={{ animationDelay: `${(duration / steps) * i}ms` } as CSSProperties} />
))}
</div>
</div>
);
}01 ウェルカムモーダルのステッパー — 計測 累積面積 23.541% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · チュートリアル UI デザイン9選 — コピペOKのツアー部品
このデモが属する記事
チュートリアル UI デザイン9選 — コピペOKのツアー部品チュートリアル ui デザインは、登録したばかりの人に「次にどこを押せばいいか」を画面上で直接指し示す部品です。チェックリスト・スポットライト・ステップ式ツールチップなど、形はだいたい決まっているので、選んで貼るだけで済みます。実際に動かし この記事のデモをすべて見る


