TocTooltipMachine: From 3 States to 4
Hover a TOC item. The tooltip appears. Move to the next item. The tooltip vanishes, then reappears 650ms later. That's not polish -- that's a gap.
In Part IV of the state machine series, I documented the TocTooltipMachine as a clean 30-line, 3-state machine. It worked. But scanning the sidebar fast revealed a problem: every pointerLeave destroyed the tooltip instantly, and the next item had to restart the full show-delay cycle from scratch.

The Problem: Death by pointerLeave
The original pointerLeave was binary -- visible or gone:
function pointerLeave(): void {
if (state === 'waiting') callbacks.onCancel();
if (state === 'visible') callbacks.onHide();
state = 'hidden';
pendingData = null;
}function pointerLeave(): void {
if (state === 'waiting') callbacks.onCancel();
if (state === 'visible') callbacks.onHide();
state = 'hidden';
pendingData = null;
}When the cursor moved from item A to item B, two things happened in quick succession: pointerLeave on A (tooltip gone) then pointerEnter on B (starts 650ms timer). The user saw a blink. For fast scanners moving down the sidebar, every item blinked independently. The tooltip felt disconnected from the cursor.
The Fix: A Fourth State
The insight: between "visible" and "hidden", insert a brief forgiveness window where the tooltip starts fading but stays alive. If the user re-enters within that window, the tooltip snaps back.
Before -- 3 states:
After -- 4 states:
The leaving state buys 280ms. If the grace timer expires, the tooltip disappears for real. If the user hovers a new item in time, the machine cancels the timer, restores to visible, and swaps content in-place.
The State Machine
Two functions changed. pointerEnter gained two new branches (for visible and leaving), and pointerLeave from visible now enters leaving instead of hidden:
function pointerEnter(data: TocTooltipData): void {
if (state === 'waiting') {
callbacks.onCancel();
pendingData = data;
callbacks.onSchedule(config.showDelay, 'show');
} else if (state === 'visible') {
// Already showing — swap content in-place
pendingData = data;
callbacks.onUpdate(data);
} else if (state === 'leaving') {
// Fading out — cancel grace timer, restore to visible
callbacks.onCancel();
pendingData = data;
state = 'visible';
callbacks.onUpdate(data);
} else {
pendingData = data;
state = 'waiting';
callbacks.onSchedule(config.showDelay, 'show');
}
}
function pointerLeave(): void {
if (state === 'waiting') {
callbacks.onCancel();
state = 'hidden';
pendingData = null;
} else if (state === 'visible') {
state = 'leaving';
callbacks.onBeginLeave();
callbacks.onSchedule(config.leaveGrace, 'hide');
}
}function pointerEnter(data: TocTooltipData): void {
if (state === 'waiting') {
callbacks.onCancel();
pendingData = data;
callbacks.onSchedule(config.showDelay, 'show');
} else if (state === 'visible') {
// Already showing — swap content in-place
pendingData = data;
callbacks.onUpdate(data);
} else if (state === 'leaving') {
// Fading out — cancel grace timer, restore to visible
callbacks.onCancel();
pendingData = data;
state = 'visible';
callbacks.onUpdate(data);
} else {
pendingData = data;
state = 'waiting';
callbacks.onSchedule(config.showDelay, 'show');
}
}
function pointerLeave(): void {
if (state === 'waiting') {
callbacks.onCancel();
state = 'hidden';
pendingData = null;
} else if (state === 'visible') {
state = 'leaving';
callbacks.onBeginLeave();
callbacks.onSchedule(config.leaveGrace, 'hide');
}
}The callback interface grew by two members -- onBeginLeave (start the CSS fade) and onUpdate (swap content without destroying the tooltip). The existing onSchedule gained a purpose parameter so the DOM adapter knows whether the timer should call machine.show() or machine.hide():
export interface TocTooltipCallbacks {
onShow: (data: TocTooltipData) => void;
onHide: () => void;
onBeginLeave: () => void;
onUpdate: (data: TocTooltipData) => void;
onSchedule: (delayMs: number, purpose: 'show' | 'hide') => void;
onCancel: () => void;
}export interface TocTooltipCallbacks {
onShow: (data: TocTooltipData) => void;
onHide: () => void;
onBeginLeave: () => void;
onUpdate: (data: TocTooltipData) => void;
onSchedule: (delayMs: number, purpose: 'show' | 'hide') => void;
onCancel: () => void;
}Here's how the full interaction flows -- the user hovers "About", moves away, then hovers "Blog" within the grace period:
The DOM Side: Typewriter Swap
When onUpdate fires, the title doesn't just snap to the new text. It typewriter-replaces, character by character:
function typewriterReplace(el: HTMLElement, newText: string, charDelay = 18): void {
let i = 0;
el.textContent = '';
function tick(): void {
if (i < newText.length) {
el.textContent += newText[i++];
typewriterRaf = requestAnimationFrame(() => setTimeout(tick, charDelay));
} else {
typewriterRaf = null;
}
}
tick();
}function typewriterReplace(el: HTMLElement, newText: string, charDelay = 18): void {
let i = 0;
el.textContent = '';
function tick(): void {
if (i < newText.length) {
el.textContent += newText[i++];
typewriterRaf = requestAnimationFrame(() => setTimeout(tick, charDelay));
} else {
typewriterRaf = null;
}
}
tick();
}Description and tags swap immediately. Position slides smoothly -- the existing CSS transition on #toc-tooltip (left 0.15s ease-out, top 0.15s ease-out) handles that for free. The visual feedback during the grace period is a single CSS rule:
#toc-tooltip.leaving {
opacity: 0.35;
}#toc-tooltip.leaving {
opacity: 0.35;
}The onBeginLeave callback adds the leaving class; onUpdate removes it and restores visible. Because the base #toc-tooltip already transitions opacity 0.2s ease, the fade-out and snap-back are animated automatically.
Testing Without Timers
The key new test: re-entry from the leaving state cancels the grace timer and updates in-place, never hiding the tooltip:
'pointerEnter while leaving cancels grace and updates in-place'() {
const { machine, callbacks } = setup();
machine.pointerEnter(makeData({ title: 'First' }));
machine.show();
machine.pointerLeave(); // → leaving
machine.pointerEnter(makeData({ title: 'Second' })); // → visible
expect(machine.getState()).toBe('visible');
expect(callbacks.onCancel).toHaveBeenCalledOnce();
expect(callbacks.onUpdate).toHaveBeenCalledOnce();
expect(callbacks.onHide).not.toHaveBeenCalled();
expect(machine.getData()!.title).toBe('Second');
}'pointerEnter while leaving cancels grace and updates in-place'() {
const { machine, callbacks } = setup();
machine.pointerEnter(makeData({ title: 'First' }));
machine.show();
machine.pointerLeave(); // → leaving
machine.pointerEnter(makeData({ title: 'Second' })); // → visible
expect(machine.getState()).toBe('visible');
expect(callbacks.onCancel).toHaveBeenCalledOnce();
expect(callbacks.onUpdate).toHaveBeenCalledOnce();
expect(callbacks.onHide).not.toHaveBeenCalled();
expect(machine.getData()!.title).toBe('Second');
}No timer mocking. No fake clocks. pointerLeave() moves the state to leaving, pointerEnter() moves it back to visible. The test asserts that onHide was never called -- the tooltip survived the gap. 21 tests pass, covering the full lifecycle including the new leaving state.
The Pattern
When a binary transition (visible/hidden) feels abrupt, insert a transient state that buys time for the user to change their mind. The leaving state is a forgiveness window -- 280ms where the system says "I'm about to hide this, but I'll wait."
The cost: one new state, two new callbacks, ~20 lines of logic in the state machine. Zero changes to the DOM event wiring -- still the same pointerenter/pointerleave listeners. All the visual polish (typewriter, fade, slide) lives in CSS and the adapter. The machine doesn't know about any of it.