04Stepped tooltip tour
Three ink-colored tooltips slide sideways as the next and previous buttons fire, and a duplicate of the first slide sits at the end so wrapping past the last one keeps moving the same direction instead of rewinding.
Best forProduct tours that walk through several steps in order
첫 장 복제translateX 단계radius 12

<main class="stage is-demo">
<div class="tour">
<div class="view">
<!-- 슬라이드 3장 + 맨 뒤에 1번 장의 복제 1장.
마지막에서 처음으로 돌아올 때 되감지 않고 같은 방향으로 계속 간다 -->
<div class="track" aria-hidden="true">
<div class="slide"><b>1 / 3</b><p>Create a project first</p></div>
<div class="slide"><b>2 / 3</b><p>Invite your teammates</p></div>
<div class="slide"><b>3 / 3</b><p>Hit your first deploy</p></div>
<div class="slide"><b>1 / 3</b><p>Create a project first</p></div>
</div>
</div>
<div class="nav">
<span class="nb" role="img" aria-label="Previous step"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M160,48V208L80,128Z" opacity="0.2"/><path d="M163.06,40.61a8,8,0,0,0-8.72,1.73l-80,80a8,8,0,0,0,0,11.32l80,80A8,8,0,0,0,168,208V48A8,8,0,0,0,163.06,40.61ZM152,188.69,91.31,128,152,67.31Z"/></svg></span>
<span class="nb nb--go" role="img" aria-label="Next step"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M176,128,96,208V48Z" opacity="0.2"/><path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34ZM104,188.69V67.31L164.69,128Z"/></svg></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 1 → 2 → 3 → 1(복제) 네 칸이 한 바퀴
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.tour {
box-sizing: border-box;
width: min(100%, 264px);
}
// 창은 고정, 안쪽 줄만 움직인다 (051 교훈: 스크롤 상자와 트랙을 분리)
.view {
overflow: hidden;
border-radius: $r-card;
}
.track {
display: flex;
width: 400%; // 슬라이드 4장(3장 + 1번 복제)
transform: translateX(0);
}
.stage.is-demo .track { animation: walk $duration $easing infinite; }
.slide {
box-sizing: border-box;
width: 25%;
flex: 0 0 25%; // gap 을 쓰지 않는다 — 4장 사이 gap 은 3개뿐이라 -75% 가 어긋난다 (BD15)
padding: $sp-4;
background: #241f29;
color: #fff;
}
.slide b {
display: block;
font-size: 11px; font-weight: 700;
color: $subject-blue-light; // 어두운 슬라이드 면 위 파란 글자 — $subject-blue 는 3.56:1
}
.slide p {
margin: $sp-2 0 0;
font-size: 13px; font-weight: 600;
line-height: 1.4;
}
.nav { display: flex; justify-content: flex-end; gap: $sp-2; margin: $sp-3 0 0; }
.nb {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px;
border-radius: $r-control;
background: #332c3a;
color: #cbd5e1;
}
.nb svg { width: 16px; height: 16px; display: block; }
.nb--go { background: $color; color: #fff; }
.stage.is-demo .nb--go {
animation: press $duration $easing infinite,
nb-jitter $duration $ease-spring infinite; // 3차: 개별 속성(translate:) 지터
translate: 0 0; // 중립 자세 — 꺼져도 제자리
}
// 한 겹(버튼 감쇠 반동 + 카운터 탄성 팝) — 2026-09-15 형태 2차
// 찌그러짐(.88) 뒤 1.08 → .97 로 되튕기며 정착. 세 구간 모두 같은 패턴,
// 시점만 25% 간격 오프셋
@keyframes press {
0%, 16% { transform: scale(1); }
20% { transform: scale(.88); }
24% { transform: scale(1.08); }
28%, 41% { transform: scale(1); }
45% { transform: scale(.88); }
49% { transform: scale(1.08); }
53%, 66% { transform: scale(1); }
70% { transform: scale(.88); }
74% { transform: scale(1.08); }
78%, 100% { transform: scale(1); }
}
// 카운터(1/3 b) 팝 — 트랙의 도착 시점(16%/41%/66%/91%) 뒤 8% 폭의 팝.
// 네 b 가 동시에 움직이지만 뷰 밖 슬라이드는 보이지 않으니 화면상
// "도착한 슬라이드의 카운터가 팡" 으로만 보인다
.stage.is-demo .slide b {
animation: cnt-pop $duration $ease-lerp-settle infinite;
}
@keyframes cnt-pop {
0%, 16% { transform: scale(1); }
20% { transform: scale(1.3); }
24% { transform: scale(.95); }
28%, 41% { transform: scale(1); }
45% { transform: scale(1.3); }
49% { transform: scale(.95); }
53%, 66% { transform: scale(1); }
70% { transform: scale(1.3); }
74% { transform: scale(.95); }
78%, 91% { transform: scale(1); }
95% { transform: scale(1.3); }
98%, 100% { transform: scale(1); }
}
// 네 칸을 같은 방향으로. -75% 에 서 있는 4번 장은 1번 장과 같은 그림이라
// 다음 바퀴가 0% 에서 다시 시작해도 이음매가 보이지 않는다
@keyframes walk {
0%, 16% { transform: translateX(0); }
25%, 41% { transform: translateX(-25%); }
50%, 66% { transform: translateX(-50%); }
75%, 100% { transform: translateX(-75%); }
}
// 칸이 넘어가는 순간마다 "다음" 버튼이 눌린 티를 낸다
@keyframes press {
0%, 16% { transform: scale(1); }
20% { transform: scale(.88); }
24%, 41% { transform: scale(1); }
45% { transform: scale(.88); }
49%, 66% { transform: scale(1); }
70% { transform: scale(.88); }
74%, 100% { transform: scale(1); }
}
// 3차: press 가 찌그러지는 세 순간(20/45/70%)에 개별 속성(translate:)으로
// 1~2px 지터를 얹는다 — 탭이 눌리며 살짝 흔들리다 멈추는 손맛. scale 과 별개 축이라
// 순수 병렬 합성(자리 이동 + 크기 변화가 동시에)
@keyframes nb-jitter {
0%, 18% { translate: 0 0; }
20% { translate: -1.5px 0; }
21.5% { translate: 1.5px 0; }
23%, 43% { translate: 0 0; }
45% { translate: -1.5px 0; }
46.5% { translate: 1.5px 0; }
48%, 68% { translate: 0 0; }
70% { translate: -1.5px 0; }
71.5% { translate: 1.5px 0; }
73%, 100% { translate: 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .track { animation: none; transform: translateX(0); }
.stage.is-demo .nb--go { animation: none; translate: 0 0; }
.stage.is-demo .slide b { animation: none; }
}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);
}
.tour {
box-sizing: border-box;
width: min(100%, 264px);
}
.view {
overflow: hidden;
border-radius: 16px;
}
.track {
display: flex;
width: 400%;
transform: translateX(0);
}
.stage.is-demo .track {
animation: walk 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.slide {
box-sizing: border-box;
width: 25%;
flex: 0 0 25%;
padding: 16px;
background: #241f29;
color: #fff;
}
.slide b {
display: block;
font-size: 11px;
font-weight: 700;
color: #5b8cf8;
}
.slide p {
margin: 8px 0 0;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
}
.nav {
display: flex;
justify-content: flex-end;
gap: 8px;
margin: 12px 0 0;
}
.nb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 12px;
background: #332c3a;
color: #cbd5e1;
}
.nb svg {
width: 16px;
height: 16px;
display: block;
}
.nb--go {
background: #2f6df6;
color: #fff;
}
.stage.is-demo .nb--go {
animation: press 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, nb-jitter 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
translate: 0 0;
}
@keyframes press {
0%, 16% {
transform: scale(1);
}
20% {
transform: scale(0.88);
}
24% {
transform: scale(1.08);
}
28%, 41% {
transform: scale(1);
}
45% {
transform: scale(0.88);
}
49% {
transform: scale(1.08);
}
53%, 66% {
transform: scale(1);
}
70% {
transform: scale(0.88);
}
74% {
transform: scale(1.08);
}
78%, 100% {
transform: scale(1);
}
}
.stage.is-demo .slide b {
animation: cnt-pop 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;
}
@keyframes cnt-pop {
0%, 16% {
transform: scale(1);
}
20% {
transform: scale(1.3);
}
24% {
transform: scale(0.95);
}
28%, 41% {
transform: scale(1);
}
45% {
transform: scale(1.3);
}
49% {
transform: scale(0.95);
}
53%, 66% {
transform: scale(1);
}
70% {
transform: scale(1.3);
}
74% {
transform: scale(0.95);
}
78%, 91% {
transform: scale(1);
}
95% {
transform: scale(1.3);
}
98%, 100% {
transform: scale(1);
}
}
@keyframes walk {
0%, 16% {
transform: translateX(0);
}
25%, 41% {
transform: translateX(-25%);
}
50%, 66% {
transform: translateX(-50%);
}
75%, 100% {
transform: translateX(-75%);
}
}
@keyframes press {
0%, 16% {
transform: scale(1);
}
20% {
transform: scale(0.88);
}
24%, 41% {
transform: scale(1);
}
45% {
transform: scale(0.88);
}
49%, 66% {
transform: scale(1);
}
70% {
transform: scale(0.88);
}
74%, 100% {
transform: scale(1);
}
}
@keyframes nb-jitter {
0%, 18% {
translate: 0 0;
}
20% {
translate: -1.5px 0;
}
21.5% {
translate: 1.5px 0;
}
23%, 43% {
translate: 0 0;
}
45% {
translate: -1.5px 0;
}
46.5% {
translate: 1.5px 0;
}
48%, 68% {
translate: 0 0;
}
70% {
translate: -1.5px 0;
}
71.5% {
translate: 1.5px 0;
}
73%, 100% {
translate: 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .track {
animation: none;
transform: translateX(0);
}
.stage.is-demo .nb--go {
animation: none;
translate: 0 0;
}
.stage.is-demo .slide b {
animation: none;
}
}import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SteppedTooltipTour.module.scss";
const CaretLeft = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
<path d="M160,48V208L80,128Z" opacity="0.2" />
<path d="M163.06,40.61a8,8,0,0,0-8.72,1.73l-80,80a8,8,0,0,0,0,11.32l80,80A8,8,0,0,0,168,208V48A8,8,0,0,0,163.06,40.61ZM152,188.69,91.31,128,152,67.31Z" />
</svg>
);
const CaretRight = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor">
<path d="M176,128,96,208V48Z" opacity="0.2" />
<path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34ZM104,188.69V67.31L164.69,128Z" />
</svg>
);
export type SteppedTooltipTourProps = {
duration?: number;
easing?: string;
color?: string;
steps?: string[];
/** 0 이면 자동 진행 없음 */
autoPlayMs?: number;
};
export default function SteppedTooltipTour({
duration = 420,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
steps = ["Create a project first", "Invite your teammates", "Hit your first deploy"],
autoPlayMs = 1400,
}: SteppedTooltipTourProps) {
// 마지막 뒤에 1번 슬라이드를 한 장 더 붙인다. 끝에서 처음으로 갈 때
// 뒤로 되감지 않고 같은 방향으로 한 칸 더 가면 되고, 그림이 같으니 이음매가 안 보인다
const slides = steps.concat(steps.length > 0 ? [steps[0]] : []);
const clone = steps.length;
const [i, setI] = useState(0);
const [anim, setAnim] = useState(true);
const next = () => { setAnim(true); setI((v) => (v >= clone ? v : v + 1)); };
const prev = () => {
// 0 에서 뒤로 가면 먼저 복제 슬라이드로 순간 이동한다 (그림이 같아 티가 안 난다)
if (i === 0) { setAnim(false); setI(clone); } else { setAnim(true); setI((v) => v - 1); }
};
// 전환 없이 자리만 옮긴 다음 프레임에 다시 전환을 켠다
useEffect(() => {
if (anim) return;
const id = requestAnimationFrame(() => {
setAnim(true);
if (i === clone) setI(clone - 1);
});
return () => cancelAnimationFrame(id);
}, [anim, i, clone]);
useEffect(() => {
if (!autoPlayMs) return;
const id = setTimeout(next, autoPlayMs);
return () => clearTimeout(id);
}, [i, autoPlayMs]);
// 복제 슬라이드에 도착했으면 전환을 끄고 진짜 0번으로 돌려놓는다
const onEnd = () => { if (i === clone) { setAnim(false); setI(0); } };
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--count": slides.length,
} as CSSProperties;
const track: CSSProperties = {
width: `${slides.length * 100}%`,
transform: `translateX(-${(100 / slides.length) * i}%)`,
transition: anim ? `transform ${duration}ms ${easing}` : "none",
};
return (
<div className={styles.tour} style={vars}>
<div className={styles.view}>
<div className={styles.track} style={track} onTransitionEnd={onEnd}>
{slides.map((text, n) => (
<div className={styles.slide} key={n} style={{ width: `${100 / slides.length}%`, flex: `0 0 ${100 / slides.length}%` }}>
<b>{(n % steps.length) + 1} / {steps.length}</b>
<p>{text}</p>
</div>
))}
</div>
</div>
<div className={styles.nav}>
<button type="button" className={styles.nb} onClick={prev} aria-label="Previous step"><CaretLeft /></button>
<button type="button" className={`${styles.nb} ${styles.go}`} onClick={next} aria-label="Next step"><CaretRight /></button>
</div>
</div>
);
}04 Stepped tooltip tour — Measured area 2.407% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Onboarding UI Design — 9 Copy-Paste Tour Widgets
The post this demo lives in
Onboarding UI Design — 9 Copy-Paste Tour WidgetsGood onboarding ui design points to the next thing to click, on the screen the user already sees. The shapes are fixed — See all demos in this post

