The second screen of nakodo.app is a mock of a creator's page in the app: charts, a fit panel, a line about where the email address came from. On its own it is a picture, and a picture of a product tells you nothing about which part of it matters. So it explains itself. Once it is on screen, a note opens next to one detail, then moves to the next, five times, and after that each note still opens when you hover the thing it belongs to.
Go and scroll to it before reading on, because the first design decision is only visible while you do.
The notes are part of the page
A tooltip library puts the note in a portal at the end of body and positions it with script against the viewport. That is the right answer when the trigger can be anywhere, including inside a scroll container, and it is the wrong answer here. A tour runs for fifteen seconds. During those fifteen seconds people scroll, because they are reading. A portalled note has to be repositioned on every scroll frame, and it is repositioned a frame late, so it visibly judders against the thing it is pointing at.
A note that lives inside the plate's own flow scrolls with the plate exactly, because it is the plate. So ShowcaseStep renders a span with position: relative and the note inside it:
<span className={cn("relative", plain ? "inline-flex" : "inline-block")}>
<button ref={ref} type="button" aria-describedby={noteId} ...>{children}</button>
<span className={cn("pointer-events-none absolute z-20", place.note)}>
<span id={noteId} role="tooltip" ...>
No portal, no positioning script, nothing to recompute. The cost is that the note cannot escape an overflow clip, and the plate has no overflow clip, so there is no cost.
Placement is a lookup table of four sides, each with three sets of classes: where the note sits, where its little rotated square of an arrow sits, and which way it travels while it is shut.
top: {
note: "bottom-full left-0 pb-2 sm:left-1/2 sm:-translate-x-1/2",
arrow: "bottom-0 left-4 -translate-x-1/2 translate-y-1/2 sm:left-1/2",
shut: "translate-y-1",
},
Everything before the sm: prefix is the phone. There is no room on a phone to centre a 280px note on a six character label, so on a small screen every note starts at the left edge of what it explains, and the two side placements give up and drop underneath instead. The arrow follows to left-4. One table, four sides, two layouts, no media query in JavaScript.
Open is a derived value, and the note is never unmounted
const open = showing || (tour.done && hovered);
Two states, and they do not overlap. While the tour runs, the tour decides. Once it has finished, the pointer decides, for ever. There is no third state where both are true, because a hover during the tour sets held on the container and holds the current step instead.
The note is always rendered. Only its classes change:
open
? "pointer-events-auto translate-none opacity-100 duration-200 ease-out"
: cn("opacity-0 duration-150 ease-in", place.shut)
Three things fall out of that. It fades out as well as in, which a conditionally rendered note cannot do without a presence library. Only the open one accepts the pointer, so four invisible notes are not sitting over the plate stealing hovers. And a screen reader can read the note as the step's description at any time, because aria-describedby points at an element that exists whether or not it is visible.
You can check it from the console on the live page:
[...document.querySelectorAll('[role="tooltip"]')].map(n => getComputedStyle(n).opacity)
Five entries, and before the tour starts every one of them is "0".
It waits for you
The tour does not start on mount. The charts grow in first (that is a CSS animation, declared paused and started by a class), and a note that opens over a chart still animating is a note nobody reads. So:
const box = el.getBoundingClientRect();
if (box.top < window.innerHeight * 0.75 && box.bottom > 0) {
start(START_MS.mounted);
return () => window.clearTimeout(timer);
}
const observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
start(START_MS.scrolled);
}, { threshold: 0.25 });
Two openings, two delays: { scrolled: 1300, mounted: 700 }. If the plate is already on screen when the component mounts, the charts never animated in, so a shorter beat is enough. If it is below the fold, the observer waits, and the delay covers the animation it just triggered.
I loaded the page and polled for which note was open, about every three quarters of a second, without scrolling. Nothing opened, for the eleven seconds I watched. Then I scrolled the plate to 40% of the viewport and polled again: the first note was open within two seconds, and the tour walked forward from there. That is the behaviour the observer is for, and it is also the easiest part of this to get wrong, because a tour that has already played by the time you arrive is a tour you will swear never existed.
There is a second observer, per step, with a much stricter threshold:
const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), { threshold: 1 });
On a phone the plate is taller than the viewport, so a step near the bottom can come due while it is off screen. threshold: 1 means the step reports in view only when all of it is, and the timer for the next step does not run while inView is false. The tour waits there until you scroll to it rather than playing to nobody.
Ending it is not the same as skipping
const onKey = (e: KeyboardEvent) => e.key === "Escape" && end();
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
if (!ref.current?.contains(e.target as Node)) end();
};
end() sets current to count, which is past the last step, which is also the state the tour lands in when it finishes normally. One state for "over", reached three ways: it ran out, you pressed Escape, you clicked somewhere else. After it, hover still works, so ending the tour costs you nothing.
The pointerType check is the part that came from a phone. A touch outside the plate is almost always the start of a scroll, and dismissing a tour because someone scrolled is a dark little bug: the tour was only in the way on the device where it was hardest to read.
One more piece of bookkeeping that exists purely for the look of it:
useEffect(() => {
if (current < count) return;
const timer = window.setTimeout(() => setGuiding(false), FADE_MS);
return () => window.clearTimeout(timer);
}, [current, count]);
guiding is what puts the 1/5 counter and the Next link inside a note. When the tour ends, the last note is still fading out for 150ms. Dropping the counter in the same frame would make the note shrink as it goes, so guiding outlasts the tour by exactly one fade. tabIndex={showing ? 0 : -1} on that Next link is the matching detail for the keyboard: four of the five Next links are invisible at any moment, and none of them should be tabbable.
Finally, the hook order. ShowcaseStep throws if it is used outside a Showcase, and the throw is below both useState calls and the effect, not above them. A guard clause before the hooks reads better and breaks the rules; hooks run unconditionally, then the component refuses to render.
The app itself is behind a login, so this plate is where the real screen gets explained: the same panels in the same order as the creator page a campaign fills in. How it works is the long version in words, and the guides are the same ideas written for people who would rather do all of this by hand.
Top comments (0)