Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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 tooltip staying alive as the cursor moves between TOC items -- fading during the grace period, then snapping back with a typewriter title swap
The tooltip staying alive as the cursor moves between TOC items -- fading during the grace period, then snapping back with a typewriter title swap


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;
}

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:

Diagram
The original three-state machine: pointerLeave from visible collapsed straight back to hidden, which is exactly what produced the blink between adjacent TOC items.

After -- 4 states:

Diagram
The new four-state machine inserts a leaving state between visible and hidden, giving the cursor a 280ms window to re-enter a TOC item and snap back to visible.

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');
  }
}

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;
}

Here's how the full interaction flows -- the user hovers "About", moves away, then hovers "Blog" within the grace period:

Diagram
The full cross-item flow: leaving the first item begins a 280ms fade, and re-entering a second item within that window cancels the hide timer and swaps content in place.

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();
}

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;
}

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');
}

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.

⬇ Download