GODRICH

Onboarding UI Design — 9 Copy-Paste Tour Widgets

Good onboarding ui design points to the next thing to click, on the screen the user already sees.

Auto-plays · click a tile to jump to its section · all nine in one zip

They are ordered the way a brand-new account meets them, not by popularity. A welcome modal (01) opens first, and then a checklist (02) holds the remaining work. When someone can't find a control, a coachmark (03) dims the screen and lights up one spot; when several controls need explaining in order, a stepped tooltip tour (04) walks forward and back. A progress strip (05) says how far along things are, a hint balloon (06) and a pulse badge (07) sit beside features nobody opens, an empty-state card (08) stands in for a list with nothing in it, and confetti (09) closes out the flow.

01Welcome modal stepper

The modal rises 18px as it opens, and of the three dots below the text, only the current one stretches sideways into a pill. The dot grows with scaleX rather than width, so nothing around it reflows.

translateY 18px점→알약radius 16
.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; }
}

02Checklist progress card

The two remaining rows tick half a cycle apart, and a strikethrough draws across each label from the left. The bar at the top of the card steps from 50% to 75% to 100% on the same beat.

scaleX 취소선50%→75%radius 16
.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); }
}

03Coachmark spotlight

A scrim covers the screen with exactly one hole lighting up the target, and the hole travels from the left button to the right card. The scrim is not a separate overlay element — it is the 9999px spread of one box-shadow on the hole itself.

box-shadow 스포트라이트구멍 이동radius 12
.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); }
}

04Stepped tooltip tour

Tooltips slide sideways each time the next button is pressed, and wrapping from the last step to the first never rewinds. A copy of slide one sits at the end of the track, so the motion always continues in the same direction.

첫 장 복제translateX 단계radius 12
// four slides, not three — the last one is a copy of slide 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%); }
}

05Progress strip

Five segments fill from the left, 0.2s apart, and only the one that just filled gets a white veil that fades straight back out. Each segment is a fixed 47px rather than an equal-flex share, so the filled fraction matches the number of steps you wrote.

5칸 세그먼트채워진 칸 점등radius 999
.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); }
}

06Hint balloon

The balloon drifts 4px up and down above the control it points at, with its icon tucked inside. Its triangular tail is an ::after on the balloon rather than a separate element, so the two never drift apart mid-animation.

translateY 4px꼬리 일체형radius 12
.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); }
}

07Feature highlight pulse badge

A ring expands to 2.4 times the dot's size and fades out behind it. Taking the dot out of flow with position: absolute means the ring can grow as large as it likes without nudging the line height of the menu row.

scale 1→2.4레이아웃 불변radius 50%
.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); }
}

08Empty state guide card

The dashed border travels slowly in one direction. CSS border-style: dashed cannot be animated, so this moves stroke-dashoffset on an SVG rect instead, shifting exactly one notch (10+8=18) per cycle so the loop has no seam.

점선 흐름dashoffsetradius 16
// the border is an SVG rect with stroke-dasharray: 10 8, not border: dashed
.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; }
}

09Completion confetti

An orange seal squashes and springs back while twelve pieces drop from above it. Every piece carries its own horizontal position, color, tilt, and start time, which is what keeps them from reading as one batch released on a single frame.

12조각 시차scale 0.8→1.12radius 50%
.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)); }
}

Where it breaks — your stagger silently becomes zero

The first render of 01 and 05 had no stagger at all. All three dots lit up together, and all five segments filled at once. The cause was writing animation-delay in one low-specificity rule while the shared rule used the shorthand animation: fill 2s ease-out infinite. A shorthand resets every sub-property you leave out, so the higher-specificity shared rule won and knocked the delay back to its initial 0s. Reading the computed style in the browser confirmed it: all five segments reported an animationDelay of 0s. Rewriting the shared rule as separate animation-name, animation-duration, animation-timing-function, and animation-iteration-count declarations brought the stagger back, and the moving-frame count for 05 went from 8 of 23 to 17 of 23. If a delay is going to arrive from another rule or from an inline style, never put the animation shorthand on that element.

The second thing to break was the segment width in 05. The five segments started out as flex: 1 1 0 equal shares with only the fill animated by scaleX, and the check step rejected that pairing outright. Equal-flex leaves the rendered width of each segment up to whatever the container happens to be, so it can drift away from a geometry whose fill ratio is computed from a fixed number. A fill written for a 78px segment running inside a segment that is really 62px throws every ratio off. Swapping the segments to flex: 0 0 auto at a fixed 47px settled it. At the 480px demo size the two versions look identical; the drift only shows up once the container is a width the fill was never written for.

Item 09 had to be reworked for a different reason. Starting the twelve confetti pieces outside the top edge (top: -12px) and dropping them 168px added their bounding boxes to the demo's own height, which put a scrollbar in its cell of the grid. The measure for "does this demo fit its own frame" is scrollHeight, and an element pushed away by transform still counts toward it. Nothing shrinks that number unless the box is genuinely clipped, whether by overflow: hidden or clip. Putting overflow: hidden on the .scene that holds the pieces brought it back into range.

The icons are not hand-drawn: eight Phosphor Duotone glyphs were downloaded and their SVG markup pasted inline. The first host returned 403 on all eight requests, and switching to the jsdelivr @phosphor-icons/core path fetched the set in one pass. Because the markup is baked into the file, nothing is fetched at runtime, and the dependency count stays at zero. Icons faked from stacked <div>s and border-radius drift the moment you scale them, while a real SVG only needs its width changed. One last measurement: of the nine, 07's pulse badge covers the least ground at 0.12% changed area, since an 8px dot and the ring behind it are all there is to move. The password for the archive is gs9cg6k2, and typing it against the downloaded zip opens all nine folders at once.

Accessibility

In a bright onboarding palette, the thing that usually fails isn't motion — it's text contrast. Pastels and orange look great but still miss the body-text threshold on white. So all nine keep their text in ink (#17141a) or slate (#475569) and spend the pastels only on backgrounds, borders, and dots. The numbers below come from running the WCAG 2.1 relative-luminance formula on the exact pairs used.

Text Background Ratio Body (4.5:1)
Ink #17141a White card #ffffff 18.24:1 Passes
Ink #17141a Mint balloon #4cd4a6 9.79:1 Passes
Slate #475569 White card #ffffff 7.58:1 Passes
Brand blue #2f6df6 White card #ffffff 4.53:1 Passes, barely
White #ffffff Orange seal #ff4d1f 3.32:1 Fails — large text only
Mint #4cd4a6 White card #ffffff 1.86:1 Fails — decoration only

That 3.32:1 row is why the "All done" line in 09 sits on the tinted background in ink rather than inside the orange seal in white. On the motion side, every one of the nine settles into its end state under prefers-reduced-motion: reduce: 03 leaves the hole parked on its first target, 05 shows all five segments full, and 09 drops no pieces and keeps only the seal. Both rules are documented on MDN's prefers-reduced-motion page and on the WCAG contrast minimum page.

FAQ

Does the coachmark scrim need its own full-screen div?

No. As in 03, putting box-shadow: 0 0 0 9999px on the element that forms the hole turns everything outside it into the scrim, and overflow: hidden on the parent stops it from leaking past the frame. A separate overlay element means recalculating two things every time the hole moves, which is exactly how they drift out of alignment.

Can I skip the duplicate slide and just jump back to the first step?

It works, but the screen visibly rewinds three panels instead of advancing one. Item 04 keeps a copy of slide one at the end so the motion is always forward, and the loop's jump back to the start is invisible because the panel it jumps from is that same slide. A similar loop is covered in CSS tooltip bubbles.

Why animate the progress bar with scaleX instead of width?

Both animate. But width recalculates layout on every frame, so in a card like 02 with several text rows, the lines below the bar can shift while it grows. transform: scaleX() with transform-origin: left center is handled by the compositor and leaves its neighbors alone. There's more on bars themselves in CSS progress bars and gauges.

Enter the archive password

The password is inside this article. You will find it as you read.