04단계 툴팁 투어
잉크색 툴팁 세 장이 다음·이전 버튼을 따라 옆으로 미끄러지고, 첫 장을 맨 뒤에 한 번 더 붙여 마지막에서 처음으로 돌아올 때도 되감기 없이 같은 방향으로 이어집니다.
쓰임여러 단계를 순서대로 짚어주는 제품 투어
첫 장 복제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>프로젝트를 먼저 만듭니다</p></div>
<div class="slide"><b>2 / 3</b><p>팀원을 초대합니다</p></div>
<div class="slide"><b>3 / 3</b><p>첫 배포를 눌러봅니다</p></div>
<div class="slide"><b>1 / 3</b><p>프로젝트를 먼저 만듭니다</p></div>
</div>
</div>
<div class="nav">
<span class="nb" role="img" aria-label="이전 단계"><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="다음 단계"><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 단계 툴팁 투어 — 측정 누적면적 2.407% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 웹 온보딩 UI 9종 — 복붙하면 되는 투어 위젯 코드
이 데모가 속한 글
웹 온보딩 UI 9종 — 복붙하면 되는 투어 위젯 코드웹 온보딩 ui 는 가입을 막 끝낸 사람에게 "이제 뭘 누르면 되는지"를 화면 위에서 직접 가리켜 주는 안내 부품입니다. 체크리스트, 스포트라이트, 단계 툴팁처럼 종류가 정해져 있어서 골라 붙이면 됩니다. 아홉 개를 이 글의 데모 전부 보기

