Scroll down the Nakodo homepage to the big bordered card with a creator on it, called Open Rate Lab. Leave the pointer alone. After about a second the card starts explaining itself: a small dark note opens beside one of its lines, waits three seconds, closes, and the next one opens somewhere else. Five notes, then it stops and gets out of the way.
That is 249 lines in src/app/(marketing)/showcase.tsx, and this post is what is in them. The card is the one place on the site where we show the product instead of describing it, so the thing pointing at its parts had to be the least annoying version of a guided tour I could build.
The notes are part of the card, not tooltips
The first decision is the one that drove the rest. Every note is a span inside the card's own layout, absolutely positioned against the thing it describes. It is not a tooltip in a portal.
The comment at the top of the file says why:
// The notes are part of the page rather than tooltips in a portal: a tooltip // is positioned by script against a fixed layer, so while a note is open and // the page scrolls it is dragged back into place a frame late and visibly // judders. A note that sits in the plate's own flow scrolls with it exactly. A tour that starts when you scroll to something is guaranteed to be open while you are still scrolling. A portal tooltip handles that by recomputing its position on every scroll event, which is always a frame behind the thing it is pointing at. A note inside the card has nothing to recompute: it moves because the card moves.
The price is that the note cannot escape its container, so there is a table of four positions, each one a set of Tailwind classes for where the note sits, which way it travels as it opens, and where its little rotated-square arrow points:
const SIDES = { 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", }, bottom: { ... }, left: { ... }, right: { ... }, } as const; Each entry has a phone shape and then an sm: shape, because there is no room to centre a note over a word on a phone, and no room at all beside a label: a left note drops underneath on a narrow screen and only moves to the side at sm.
Four positions. The homepage uses two of them: three notes are top (the default) and two are left. bottom and right have never rendered on the live site. I am leaving them, because the first time a note needs to point up at something they are the half hour I do not spend, but they are untested code and worth saying out loud.
Four timers, and what each one is waiting for
const STEP_MS = 3000; const FADE_MS = 150; const START_MS = { scrolled: 1300, mounted: 700 }; START_MS has two values because the card has an entry animation. Its bars grow in with animation: grow-x 0.7s and a 70ms stagger per bar, so a tour that starts at once points at a chart that is still drawing itself. 1300ms is that animation plus a beat. The 700ms case is for a reader who loaded the page already looking at the card, where the bars never animated at all.
Which one you get is decided once, on mount, by asking where the card is:
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 }, ); The observer disconnects itself on the first intersection, so the tour runs once per page load and scrolling back up does not restart it. FADE_MS is the odd one out: it is not a tour timing at all, it is how long a note takes to fade, and the tour needs to know because of the step counter, which I will come back to.
Two rules so the tour never talks to an empty screen
A three second timer is a promise that the reader is looking. Two things break that promise, and each one has a rule.
The pointer. The whole card has onPointerEnter and onPointerLeave, and while the pointer is inside it the step timer does not run:
useEffect(() => { if (!running || held || !inView) return; const timer = window.setTimeout(() => setCurrent((c) => c + 1), STEP_MS); return () => window.clearTimeout(timer); }, [current, running, held, inView]); Reaching for a note is the clearest signal in the whole interaction that the reader is reading, so hovering holds the tour where it is.
The viewport. On a phone the card is taller than the screen, so the note the tour wants to open next can be below the fold. Each step watches itself and tells the tour:
const observer = new IntersectionObserver( ([entry]) => setInView(entry.isIntersecting), { threshold: 1 }, ); threshold: 1 means fully visible, not partly, and inView is in that effect's guard above. A step that is off screen opens its note and then waits there until you scroll to it, rather than counting down three seconds at a reader who cannot see it.
Then two ways to say stop:
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(); }; Escape ends the tour, and so does a mouse click anywhere outside the card. pointerType !== "mouse" is the interesting half: on a phone, a touch outside the card is nearly always the start of a scroll, and ending the tour because somebody scrolled is exactly the wrong reading of that gesture. Ending means jumping to count, not skipping one step, because somebody who clicks away has said no to the whole thing.
The step counter outlasts the tour by one fade
While the tour is guiding, each note carries 3/5 and a Next button. Afterwards the notes stay, and open on hover, without either. That is one piece of state:
useEffect(() => { if (current < count) return; const timer = window.setTimeout(() => setGuiding(false), FADE_MS); return () => window.clearTimeout(timer); }, [current, count]); If guiding flipped the moment the last note closed, the last note would lose a line of content and visibly shrink while fading out. Waiting FADE_MS means the reader never sees that. This is the kind of detail nobody will ever thank you for, which is also why it is the kind of thing worth writing down: the whole fix is a 150ms timeout and knowing what it is for.
It is all in the server HTML
The note is in the DOM whether it is open or not. Open is three classes:
open ? "pointer-events-auto translate-none opacity-100 duration-200 ease-out" : cn("opacity-0 duration-150 ease-in", place.shut) So it fades both ways, and the text is readable by assistive software at any point through aria-describedby, not just during its three seconds. You can check that without running any of our JavaScript:
curl -s https://nakodo.app/ | grep -o 'role="tooltip"' | wc -l # 5 curl -s https://nakodo.app/ | grep -o 'aria-describedby' | wc -l # 5 Five of each, and 498 characters of note text, in 596,325 bytes of HTML. They are the only five on the site: no other page in our 76 has a role="tooltip" in it.
The ids are React's, from useId, and they look like _R_9irilfebrb_. They are per render and they match, which is the whole point of the hook.
The bug this post found
Writing the "it is all in the server HTML" part, I went looking for how a keyboard user gets at the notes. The answer is aria-describedby and nothing else. The step is a button, so Tab reaches it, but open is showing || (tour.done && hovered) and hovered is only ever set by onPointerEnter and by a click. Focus does not open the note. There is even an onBlur that clears hovered, which means I had thought about focus and then only handled the leaving half of it.
So that is the open work: onFocus should set hovered the way onPointerEnter does, and the tour should probably hold while a step is focused, for the same reason it holds while a step is hovered. Five lines, and the tour on nakodo.app will do the right thing for somebody who never touches the mouse.
