웹 온보딩 UI 9종 — 복붙하면 되는 투어 위젯 코드
웹 온보딩 ui 는 가입을 막 끝낸 사람에게 "이제 뭘 누르면 되는지"를 화면 위에서 직접 가리켜 주는 안내 부품입니다. 체크리스트, 스포트라이트, 단계 툴팁처럼 종류가 정해져 있어서 골라 붙이면 됩니다. 아홉 개를 실제로 돌려보고 모았습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 환영 모달 스텝퍼
- 02 체크리스트 진행 카드
- 03 코치마크 스포트라이트
- 04 단계 툴팁 투어
- 05 진행률 스트립
- 06 힌트 풍선
- 07 기능 하이라이트 펄스 배지
- 08 빈 상태 안내 카드
- 09 완료 축하 컨페티
순서는 인기순이 아니라 새로 가입한 사람이 실제로 마주치는 차례입니다. 첫 화면에서 환영 모달(01)을 보고, 남은 할 일을 체크리스트(02)로 받습니다. 버튼을 못 찾으면 코치마크(03)가 화면을 어둡게 덮고 한 곳만 비추며, 여러 단계를 짚어야 하면 툴팁 투어(04)가 다음·이전으로 넘어갑니다. 어디까지 왔는지는 진행률 스트립(05)이 알려주고, 안 쓰는 기능 옆에는 힌트 풍선(06)과 펄스 배지(07)가 붙습니다. 아직 아무것도 없는 목록에는 빈 상태 카드(08)가 대신 서 있고, 다 끝나면 컨페티(09)가 터집니다.
01환영 모달 스텝퍼
모달이 아래에서 18px 떠오르며 열리고, 아래쪽 점 세 개 중 현재 단계만 가로로 펴져 알약이 됩니다. 점을 넓히는 것은 width 가 아니라 scaleX 라서 글자 위치가 밀리지 않습니다.
.dots i {
width: 20px; height: 8px;
border-radius: $r-pill;
background: #cbd5e1;
transform: scaleX(.4);
animation-name: step;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.d2 { animation-delay: calc(#{$duration} / 3); }
@keyframes step {
0% { transform: scaleX(1); background: $color; }
33.33% { transform: scaleX(.4); background: #cbd5e1; }
}
02체크리스트 진행 카드
남은 할 일 두 줄이 반 주기씩 어긋나 체크되고, 글자에는 취소선이 왼쪽부터 그어집니다. 카드 맨 위 막대는 50%에서 75%를 거쳐 100%까지 같은 박자로 늘어납니다.
.bar i {
position: absolute; inset: 0;
background: $color;
transform: scaleX(.5);
transform-origin: left center;
}
@keyframes fill {
0%, 10% { transform: scaleX(.5); }
40%, 55% { transform: scaleX(.75); }
85%, 100% { transform: scaleX(1); }
}
@keyframes strike {
0%, 18% { transform: scaleX(0); }
44%, 100% { transform: scaleX(1); }
}
03코치마크 스포트라이트
화면을 덮은 막에 구멍이 하나만 뚫려 대상 버튼을 비추고, 그 구멍이 왼쪽 버튼에서 오른쪽 카드로 옮겨갑니다. 막은 따로 그린 요소가 아니라 구멍 하나에 건 box-shadow 의 9999px 퍼짐입니다.
.spot {
position: absolute;
left: 12px; top: 12px;
width: 120px; height: 40px;
border-radius: $r-control;
box-shadow: 0 0 0 9999px rgba(23, 20, 26, .74);
transform-origin: left center;
}
@keyframes move {
0%, 18% { transform: translateX(0) scaleX(1); }
50%, 68% { transform: translateX(124px) scaleX(.8333); }
100% { transform: translateX(0) scaleX(1); }
}
04단계 툴팁 투어
칸이 넘어갈 때마다 "다음" 버튼만 눌린 티를 내고 툴팁이 옆으로 미끄러지는데, 마지막 장에서 처음으로 돌아갈 때 뒤로 되감기지 않습니다. 맨 뒤에 1번 장을 한 장 더 붙여두고 같은 방향으로 한 칸 더 가기 때문입니다.
// 슬라이드는 3장이 아니라 4장이다 — 맨 뒤 한 장은 1번 장의 복제
.track { display: flex; width: 400%; }
.slide { width: 25%; flex: 0 0 25%; }
@keyframes walk {
0%, 16% { transform: translateX(0); }
25%, 41% { transform: translateX(-25%); }
50%, 66% { transform: translateX(-50%); }
75%, 100% { transform: translateX(-75%); }
}
05진행률 스트립
다섯 칸이 왼쪽부터 0.2초씩 늦게 차오르고, 방금 찬 칸만 흰 막이 잠깐 덮였다 걷힙니다. 칸은 균등 분배가 아니라 47px 고정 폭이라서 채움 비율이 계산과 어긋나지 않습니다.
.seg { flex: 0 0 auto; width: 47px; height: 8px; overflow: hidden; }
.fill {
position: absolute; inset: 0;
background: $color;
transform: scaleX(0);
transform-origin: left center;
animation-name: fill;
animation-duration: $duration;
}
.s2 .fill { animation-delay: .2s; }
.s3 .fill { animation-delay: .4s; }
@keyframes fill {
0% { transform: scaleX(0); }
16%, 100% { transform: scaleX(1); }
}
06힌트 풍선
말풍선이 대상 버튼 위에서 4px씩 위아래로 천천히 흔들립니다. 아래 삼각 꼬리를 별도 요소가 아니라 ::after 로 붙여 본체와 한 몸으로 움직이게 했습니다.
.hint {
position: relative;
background: $color;
transform: translateY(0);
}
.hint::after {
content: "";
position: absolute;
left: 50%; bottom: -6px;
width: 12px; height: 12px;
background: $color;
transform: translateX(-50%) rotate(45deg);
}
@keyframes nudge {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
07기능 하이라이트 펄스 배지
메뉴 오른쪽 위 점 뒤에서 링이 2.4배까지 퍼지며 사라집니다. 점을 position: absolute 로 띄워 줄 안에 자리를 차지하지 않게 하면 링이 아무리 커져도 글줄 간격이 흔들리지 않습니다.
.dot {
position: absolute;
right: 10px; top: 8px;
width: 8px; height: 8px;
border-radius: 50%;
background: $color;
}
.ring {
position: absolute; inset: 0;
border-radius: 50%;
background: $color;
opacity: .55;
}
@keyframes pulse {
0% { opacity: .55; transform: scale(1); }
70%, 100% { opacity: 0; transform: scale(2.4); }
}
08빈 상태 안내 카드
점선 테두리가 한 방향으로 천천히 흐릅니다. border-style: dashed 는 흐르게 만들 수 없어서 SVG rect 의 stroke-dashoffset 을 옮겼고, 한 주기에 정확히 한 눈금(10+8=18)만 흘려 이음매를 없앴습니다.
// 테두리는 border: dashed 가 아니라 SVG rect 의 stroke-dasharray: 10 8
.edge rect {
animation-name: flow;
animation-duration: $duration;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes flow {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -18; }
}
09완료 축하 컨페티
주황 도장이 한 번 눌렸다 튀어 오르고 조각 열두 개가 위에서 떨어집니다. 조각마다 가로 위치·색·기울기·출발 시각이 전부 달라서 한 번에 뿌려진 티가 나지 않습니다.
.b {
position: absolute;
top: -12px;
width: 6px; height: 10px;
border-radius: $r-xs;
opacity: 0;
}
.b3 { left: 26%; animation-delay: .21s; --r: 61deg; }
.b8 { left: 69%; animation-delay: .4s; --r: -29deg; }
@keyframes fall {
0% { opacity: 0; transform: translateY(0) rotate(0deg); }
12% { opacity: 1; }
78% { opacity: 1; }
100% { opacity: 0; transform: translateY(168px) rotate(var(--r)); }
}
어디서 깨지는가 — 시차를 준 줄이 통째로 무시된다
01번 점 세 개와 05번 칸 다섯 개를 처음 돌렸을 때 시차가 하나도 먹지 않았습니다. 세 점이 동시에 켜지고 다섯 칸이 한꺼번에 찼습니다. 원인은 animation-delay 를 낮은 특이도의 규칙에 따로 떼어 적고, 공통 규칙에서는 animation: fill 2s ease-out infinite 처럼 단축 속성을 쓴 것이었습니다. 단축 속성은 값을 명시하지 않은 하위 속성을 전부 초기값으로 되돌리기 때문에, 특이도가 높은 공통 규칙이 이기면서 지연이 0s 로 리셋됩니다. 브라우저에서 실제로 재보니 다섯 칸의 animationDelay 가 전부 0s 로 찍혔습니다. 공통 규칙을 animation-name·animation-duration·animation-timing-function·animation-iteration-count 낱개로 풀어 쓰자 지연이 살아났고, 05번의 움직인 프레임이 23장 중 8장에서 17장으로 늘었습니다. 시차를 다른 규칙이나 인라인 style 로 넘길 계획이라면 그 요소에는 animation 단축 속성을 절대 쓰지 않는 편이 안전합니다.
두 번째로 걸린 것은 05번 진행률 스트립의 칸 폭이었습니다. 처음에는 다섯 칸을 flex: 1 1 0 으로 균등 분배해 두고 채움만 scaleX 로 늘렸는데, 검사 단계에서 이 조합이 그대로 막혔습니다. 균등 분배는 칸의 실제 렌더링 폭을 컨테이너 사정에 맡기기 때문에, 채움 비율을 고정 수치로 계산하는 지오메트리와는 언제든 어긋날 수 있습니다. 폭이 78px 일 줄 알고 짠 채움이 실제로는 62px 짜리 칸 위에서 돌면 비율이 통째로 틀어집니다. 그래서 칸을 flex: 0 0 auto 에 47px 고정 폭으로 바꿨습니다. 480px 짜리 데모 화면에서는 두 방식이 똑같아 보입니다. 문제는 화면이 좁아질 때가 아니라, 채움을 짤 때 가정한 폭과 실제로 렌더링된 폭이 다를 때 드러납니다.
09번 컨페티는 또 다른 이유로 한 번 되돌렸습니다. 조각 열두 개를 상자 위쪽 바깥(top: -12px)에서 출발시켜 168px 아래까지 떨어뜨렸더니, 그 경계 상자가 데모 화면 높이에 그대로 더해져 격자의 09번 칸에 스크롤바가 생겼습니다. 화면 안에 들어가는지를 재는 기준은 scrollHeight 인데, transform 으로 밀려난 요소도 이 값에 잡힙니다. overflow: hidden 이든 clip 이든 상자를 실제로 잘라주지 않으면 숫자가 줄지 않습니다. 조각을 담는 .scene 에 overflow: hidden 을 걸자 값이 제자리로 돌아왔습니다.
아이콘은 손으로 그리지 않고 Phosphor Duotone 여덟 종을 받아 SVG 를 그대로 인라인했습니다. 처음 부른 Iconify 주소는 여덟 번 모두 403 을 돌려주었고, jsdelivr 의 @phosphor-icons/core 경로로 바꾸자 한 번에 전부 내려받았습니다. 파일에 박아 넣었으니 런타임에 외부를 부르는 곳은 여전히 없습니다. <div> 를 겹치거나 border-radius 로 흉내 낸 아이콘은 크기를 조금만 키워도 어긋나는데, 진짜 SVG 는 width 만 바꾸면 됩니다. 마지막으로 아홉 개의 움직임을 재보면 07번 펄스 배지가 누적면적 0.12% 로 가장 작습니다. 8px 점과 그 뒤에서 퍼지는 링이 전부라 화면에서 실제로 변하는 넓이가 그만큼 작습니다. 압축을 풀 때 넣는 비밀번호는 gs9cg6k2 이며, 받은 zip 이름 그대로 치면 폴더 아홉 개가 통째로 열립니다.
접근성
밝은 톤 온보딩에서 제일 자주 깨지는 것은 움직임이 아니라 글자 대비입니다. 파스텔과 주황은 눈에는 예쁘지만 흰 바탕에서 본문 기준을 넘기지 못합니다. 그래서 아홉 개 전부 글자는 잉크(#17141a)나 슬레이트(#475569)로 고정하고, 파스텔은 배경·테두리·점에만 썼습니다. 아래는 실제로 쓴 조합을 WCAG 2.1 상대휘도 공식으로 계산한 값입니다.
| 글자 | 바탕 | 대비 | 본문(4.5:1) |
|---|---|---|---|
잉크 #17141a |
흰 카드 #ffffff |
18.24:1 | 통과 |
잉크 #17141a |
민트 풍선 #4cd4a6 |
9.79:1 | 통과 |
슬레이트 #475569 |
흰 카드 #ffffff |
7.58:1 | 통과 |
브랜드 블루 #2f6df6 |
흰 카드 #ffffff |
4.53:1 | 통과(간당간당) |
흰 글자 #ffffff |
주황 도장 #ff4d1f |
3.32:1 | 미달 — 큰 글자만 |
민트 #4cd4a6 |
흰 카드 #ffffff |
1.86:1 | 미달 — 장식 전용 |
09번 완료 화면의 "모두 끝났어요"를 주황 도장 안에 흰 글자로 넣지 않고 바탕 위 잉크로 뺀 이유가 이 표의 3.32:1 입니다. 움직임 쪽은 아홉 개 모두 prefers-reduced-motion: reduce 에서 마지막 상태로 멈춥니다. 03번은 구멍이 첫 대상에 선 채로, 05번은 다섯 칸이 다 찬 채로, 09번은 조각이 아예 떨어지지 않고 도장만 남습니다. 설정값의 의미는 MDN 의 prefers-reduced-motion 문서 와 WCAG 대비 기준 문서 에 정리돼 있습니다.
FAQ
코치마크의 어두운 막은 화면 전체를 덮는 div 를 따로 만들어야 하나요?
아닙니다. 03번처럼 구멍이 될 요소 하나에 box-shadow: 0 0 0 9999px 를 걸면 그 바깥이 통째로 막이 됩니다. 부모에 overflow: hidden 만 걸어두면 막이 화면 밖으로 새지 않습니다. 막용 요소를 따로 두면 구멍 위치를 옮길 때마다 두 요소를 같이 계산해야 해서 오히려 어긋나기 쉽습니다.
투어 슬라이드를 복제하지 않고 마지막에서 처음으로 그냥 돌리면 안 되나요?
돌아가기는 합니다. 다만 화면이 오른쪽으로 세 칸 갔다가 왼쪽으로 세 칸 되감기며 순간이동한 것처럼 보입니다. 04번은 1번 장의 복제를 맨 뒤에 붙여 늘 같은 방향으로만 가고, 마지막에 서는 복제 장이 1번 장과 같은 그림이라 다음 바퀴가 0%에서 다시 시작해도 이음매가 보이지 않습니다. 비슷한 루프 처리는 CSS 툴팁 말풍선 편에서도 다뤘습니다.
진행률 막대는 width 로 늘리면 안 되나요?
동작은 합니다. 대신 width 는 값이 바뀔 때마다 레이아웃을 다시 계산해서, 02번처럼 카드 안에 글줄이 여럿 있으면 막대가 자랄 때 아래 줄까지 함께 흔들릴 수 있습니다. transform: scaleX() 와 transform-origin: left center 는 합성 단계에서만 처리돼 주변이 밀리지 않습니다. 막대 자체를 더 다루는 법은 진행 막대·게이지 CSS 에 있습니다.