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
- 01 Welcome modal stepper
- 02 Checklist progress card
- 03 Coachmark spotlight
- 04 Stepped tooltip tour
- 05 Progress strip
- 06 Hint balloon
- 07 Feature highlight pulse badge
- 08 Empty state guide card
- 09 Completion confetti
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.
.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.
.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.
.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.
// 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.
.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.
.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.
.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.
// 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.
.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.