Skip to main content

Building keyboard-navigable product tours in React

Build keyboard-navigable React product tours with focus trapping, arrow keys, and screen reader announcements. WCAG 2.1 AA TypeScript tutorial.

DomiDex
DomiDexCreator of Tour Kit
April 7, 20269 min read
Share
Building keyboard-navigable product tours in React

Building keyboard-navigable product tours in React

Product tours that only work with a mouse fail roughly 15% of your users. That number comes from the WebAIM Million report (2025), which found 95.9% of homepages have detectable WCAG failures. Missing keyboard support is among the top five. If your tour tooltip traps focus incorrectly or ignores arrow keys entirely, keyboard users can't advance steps, skip the tour, or even return to your app. Screen reader users hear nothing at all.

We built keyboard navigation into Tour Kit from day one because we hit these problems ourselves. useKeyboardNavigation handles arrow keys, Enter, and Escape. Focus stays trapped within each tooltip via useFocusTrap. Step changes reach screen readers through the announce() utility. All of it ships in under 8KB gzipped for @tour-kit/core (verified on bundlephobia), with zero runtime dependencies.

By the end of this tutorial, you'll have a 5-step product tour where every interaction works from the keyboard alone, screen readers announce each step transition, and focus returns to the triggering element when the tour ends.

npm install @tourkit/core @tourkit/react

What you'll build

Most React tour libraries skip keyboard accessibility entirely. Tour Kit handles three layers: arrow key step navigation (Right/Enter to advance, Left to go back, Escape to exit), focus trapping that cycles Tab within each tooltip without leaking to the page behind the overlay, and live region announcements that tell screen readers "Step 2 of 5: Configure your workspace" on every transition.

We tested this against axe-core 4.10 and VoiceOver on macOS. Zero violations. The complete keyboard-navigable tour adds roughly 2KB to your bundle on top of Tour Kit's base.

Prerequisites

  • React 18.2+ or React 19
  • TypeScript 5.0+
  • A React project with any bundler (Vite, Next.js, Remix)
  • Basic familiarity with React hooks and refs

Step 1: set up the provider with keyboard config

Tour Kit separates keyboard configuration from component rendering. You define which keys do what in the TourProvider, and every component in the subtree inherits that behavior through context. No prop drilling, no duplicated event listeners.

// src/components/KeyboardTour.tsx
import { TourProvider } from '@tourkit/react'
import type { KeyboardConfig, A11yConfig } from '@tourkit/core'
import { TourSteps } from './TourSteps'

const keyboardConfig: KeyboardConfig = {
  enabled: true,
  nextKeys: ['ArrowRight', 'Enter'],
  prevKeys: ['ArrowLeft'],
  exitKeys: ['Escape'],
  trapFocus: true,
}

const a11yConfig: A11yConfig = {
  announceSteps: true,
  ariaLive: 'polite',
  focusTrap: true,
  restoreFocus: true,
  reducedMotion: 'respect',
}

const steps = [
  { id: 'welcome', target: '#welcome-banner', title: 'Welcome', content: 'Start your workspace setup here.' },
  { id: 'sidebar', target: '#sidebar-nav', title: 'Navigation', content: 'Browse your projects from the sidebar.' },
  { id: 'search', target: '#search-input', title: 'Search', content: 'Find anything with Cmd+K.' },
  { id: 'settings', target: '#settings-btn', title: 'Settings', content: 'Customize your experience.' },
  { id: 'done', target: '#profile-menu', title: 'All set', content: 'You are ready to go.' },
]

export function KeyboardTour() {
  return (
    <TourProvider
      steps={steps}
      keyboard={keyboardConfig}
      a11y={a11yConfig}
    >
      <TourSteps />
    </TourProvider>
  )
}

KeyboardConfig accepts four properties: nextKeys, prevKeys, exitKeys (all string arrays), and a trapFocus boolean. Defaults are ArrowRight/Enter for next, ArrowLeft for previous, Escape to exit. Some teams map Space to next, but avoid removing Escape. WCAG 2.1 Success Criterion 2.1.1 requires all interactive content be operable through keyboard, and Escape for dismissal is a user expectation documented in the WAI-ARIA Dialog Pattern.

Step 2: build the tour card with focus trapping

Focus trapping prevents Tab from escaping the tooltip and landing on page elements hidden behind the overlay. Without it, keyboard users tab into invisible territory and lose their place. Tour Kit's useFocusTrap hook handles the mechanics: it finds all focusable elements inside a container, moves focus to the first one on activation, and wraps Tab/Shift+Tab between first and last elements.

// src/components/TourSteps.tsx
import { useTour, useFocusTrap } from '@tourkit/react'
import { useEffect, useRef } from 'react'

export function TourSteps() {
  const {
    isActive,
    currentStep,
    currentStepIndex,
    totalSteps,
    next,
    prev,
    skip,
    isFirstStep,
    isLastStep,
  } = useTour()

  const cardRef = useRef<HTMLDivElement>(null)
  const { containerRef, activate, deactivate } = useFocusTrap(isActive)

  useEffect(() => {
    if (isActive) {
      activate()
    } else {
      deactivate()
    }
  }, [isActive, activate, deactivate])

  if (!isActive || !currentStep) return null

  return (
    <div
      ref={(node) => {
        cardRef.current = node
        containerRef.current = node
      }}
      role="dialog"
      aria-modal="true"
      aria-labelledby={`tour-step-title-${currentStep.id}`}
      aria-describedby={`tour-step-desc-${currentStep.id}`}
      className="tour-card"
    >
      <div className="tour-card-header">
        <h2 id={`tour-step-title-${currentStep.id}`}>
          {currentStep.title}
        </h2>
        <span aria-live="polite" className="sr-only">
          Step {currentStepIndex + 1} of {totalSteps}
        </span>
      </div>

      <p id={`tour-step-desc-${currentStep.id}`}>
        {currentStep.content}
      </p>

      <div className="tour-card-footer" role="group" aria-label="Tour navigation">
        {!isFirstStep && (
          <button onClick={prev} type="button">
            Previous
          </button>
        )}
        <button onClick={isLastStep ? skip : next} type="button">
          {isLastStep ? 'Finish' : 'Next'}
        </button>
        <button onClick={skip} type="button" aria-label="Skip tour">
          Skip
        </button>
      </div>
    </div>
  )
}

Three details matter here. First, role="dialog" with aria-modal="true" tells assistive tech this is a modal surface. Screen readers will scope their virtual cursor to this container. Second, aria-labelledby and aria-describedby connect the dialog to its heading and body text, so VoiceOver reads "Welcome dialog. Start your workspace setup here." when the card opens.

Third, the navigation buttons sit inside a role="group" with aria-label="Tour navigation" so screen reader users hear the grouping context.

Step 3: add screen reader announcements

Visual step transitions are obvious to sighted users. Screen reader users need explicit announcements. Tour Kit's announce() utility creates a temporary aria-live region, inserts the step text, then cleans up after 1 second. This approach avoids polluting your DOM with persistent live regions that might conflict with other announcements in your app.

// src/components/TourSteps.tsx โ€” add to the existing component
import { announce, getStepAnnouncement } from '@tourkit/core'

// Inside TourSteps, add this useEffect:
useEffect(() => {
  if (isActive && currentStep) {
    const message = getStepAnnouncement(
      currentStep.title,
      currentStepIndex + 1,
      totalSteps,
    )
    announce(message, 'polite')
  }
}, [isActive, currentStep, currentStepIndex, totalSteps])

The getStepAnnouncement function generates a consistent format: "Step 2 of 5: Configure your workspace." We chose polite over assertive because tour step changes aren't urgent (the user initiated them). For error states or timeout warnings, switch to assertive.

How does announce() work under the hood? It creates a visually hidden <div> with role="status" and aria-live="polite", appends it to document.body, writes the message after a 100ms delay (required for screen reader registration), then removes the element after 1 second. Follows the W3C Live Region pattern and works reliably across VoiceOver, NVDA, and JAWS.

Step 4: handle edge cases keyboard users hit

Real-world keyboard navigation has gotchas that demos skip. Here are three we hit when testing Tour Kit with actual screen reader users, and how the library handles them.

Focus restoration after tour ends

When a tour finishes or gets skipped, focus needs to return to wherever it was before the tour started. If you don't restore focus, keyboard users land on <body> and have to Tab through the entire page to find their place.

Tour Kit's useFocusTrap stores document.activeElement when the trap activates and calls .focus() on that element when it deactivates. The restoreFocus: true option in A11yConfig enables this by default.

// This happens automatically inside useFocusTrap:
// On activate: previousActiveElement.current = document.activeElement
// On deactivate: previousActiveElement.current.focus()

If the original element has been removed from the DOM (it happens with dynamic UIs), focus falls back to document.body. You can override this by passing a fallbackFocus element.

Ignoring keystrokes in form fields

If a tour step highlights a form input and the user starts typing, arrow keys should type characters, not navigate the tour. Tour Kit's useKeyboardNavigation checks document.activeElement before handling keystrokes:

// From @tourkit/core useKeyboardNavigation:
if (
  document.activeElement instanceof HTMLInputElement ||
  document.activeElement instanceof HTMLTextAreaElement
) {
  return // Let the input handle the keystroke
}

This means users can interact with form fields during a tour step (when interactive: true is set on the step) without accidentally skipping steps. Arrow keys work in the input. Escape still exits the tour because that's consistent with dialog dismissal patterns.

Respecting prefers-reduced-motion

The A11yConfig.reducedMotion option has three values: 'respect' (default, reads the OS setting), 'always-animate', and 'never-animate'. When reduced motion is active, Tour Kit skips the 300ms spotlight transition and moves the tooltip instantly.

This isn't only about keyboard users. web.dev reports that approximately 1 in 4 users enable reduced motion on their devices. Your tour should respect that preference.

Step 5: customize key bindings for your app

Default key bindings work for most apps. But if your app already uses ArrowRight for something else (carousel navigation, code editors, media players), you need to remap tour controls. KeyboardConfig lets you set any combination of keys per action.

// src/components/KeyboardTour.tsx
const keyboardConfig: KeyboardConfig = {
  enabled: true,
  nextKeys: ['ArrowDown', 'n'],     // Down arrow or 'n' for next
  prevKeys: ['ArrowUp', 'p'],       // Up arrow or 'p' for previous
  exitKeys: ['Escape', 'q'],        // Escape or 'q' to quit
  trapFocus: true,
}

You can also disable keyboard navigation entirely for specific tours by setting enabled: false. This is useful for tours that target form-heavy pages where keyboard events need to pass through to the form controls.

One more pattern we've seen work well: adding a visible key hint to the tour card footer. Sighted keyboard users appreciate knowing what keys are available without having to guess.

// src/components/TourSteps.tsx โ€” add to the footer
<p className="text-xs text-muted-foreground mt-2" aria-hidden="true">
  โ†’ Next &middot; โ† Previous &middot; Esc Close
</p>

The aria-hidden="true" keeps screen readers from reading the hint text. They already get that information through button labels and live region announcements.

Common issues and troubleshooting

"Focus escapes the tooltip when I press Tab"

This happens when the focus trap isn't activated or when the containerRef isn't connected to the dialog element. Verify that you're assigning the ref from useFocusTrap to the same DOM node that has role="dialog":

const { containerRef, activate, deactivate } = useFocusTrap(isActive)

// Both refs must point to the same node:
ref={(node) => {
  cardRef.current = node
  containerRef.current = node
}}

If focus still escapes, check that your tooltip has at least one focusable element (a button, link, or element with tabIndex={0}). The focus trap needs somewhere to send focus.

"Arrow keys scroll the page instead of navigating steps"

Tour Kit calls event.preventDefault() on matched keystrokes, but only when useKeyboardNavigation is active. If your keyboard handler isn't firing, confirm the tour is active (isActive === true) and that keyboard.enabled isn't set to false in your config.

Also check that focus is inside the tour card, not on a background element. If the overlay isn't blocking clicks properly, focus can land outside the dialog where Tour Kit's key handler doesn't reach.

"Screen reader doesn't announce step changes"

The announce() function requires a 100ms delay between element creation and text insertion for screen readers to register the live region. If you're calling announce() synchronously during a React state update, the timing might be off. Move the call into a useEffect that depends on currentStepIndex:

useEffect(() => {
  if (isActive && currentStep) {
    announce(getStepAnnouncement(currentStep.title, currentStepIndex + 1, totalSteps))
  }
}, [currentStepIndex])

Next steps

You now have a fully keyboard-navigable product tour. From here, you could add multi-page support with Tour Kit's router adapters, persist tour progress across sessions with localStorage persistence, or track step completion rates with analytics integration.

If you want to test your tour's accessibility before shipping, run npx axe-core against the page with the tour open. Any WCAG violations will show up with specific fix recommendations.

Tour Kit is an open-source project (MIT license for core packages) built by a solo developer. A few honest limitations to note: there's no visual tour builder (you write steps in code), no React Native support, and the community is smaller than React Joyride's 603K weekly downloads. For teams that need a no-code editor, tools like Appcues or Userflow might be a better fit. But if you want full control over rendering and keyboard behavior, Tour Kit gives you the primitives without the opinions.

npm install @tourkit/core @tourkit/react

View the full example on GitHub | Tour Kit documentation

FAQ

Does keyboard navigation work with React 19?

Tour Kit's keyboard hooks work with both React 18.2 and React 19. The useKeyboardNavigation hook uses standard document.addEventListener('keydown'), which is unaffected by React 19's event delegation changes. We tested both versions in CI with zero regressions.

What WCAG criteria does keyboard-navigable tour support?

Tour Kit addresses WCAG 2.1 Success Criteria 2.1.1 (Keyboard), 2.1.2 (No Keyboard Trap), 2.4.3 (Focus Order), and 4.1.3 (Status Messages). The focus trap ensures Escape always exits (2.1.2), and aria-live announcements satisfy status messages (4.1.3). These four criteria cover AA compliance for keyboard interaction.

How does Tour Kit compare to React Joyride for keyboard support?

React Joyride (37KB gzipped, as of April 2026) provides basic keyboard navigation but skips focus trapping and aria-live announcements. Its tooltip renders without role="dialog", so screen readers don't scope their virtual cursor. Tour Kit ships at under 8KB gzipped with useKeyboardNavigation, useFocusTrap, and announce() built in.

Does adding keyboard navigation affect bundle size?

The keyboard and focus trap hooks add approximately 1.8KB to Tour Kit's core bundle (gzipped). That covers useKeyboardNavigation, useFocusTrap, and announce() combined. No extra dependencies required; everything uses native DOM APIs.

Can I disable keyboard navigation for specific steps?

Not yet. Tour Kit doesn't support per-step keyboard config (a current limitation). You can disable it for an entire tour with keyboard.enabled: false. For per-step control, add an onKeyDown handler that calls event.stopPropagation() to block Tour Kit's global handler on code editor or form-heavy steps.


Internal linking suggestions:

  • Link from react-onboarding-wizard.mdx (shares accessibility focus)
  • Link from react-spotlight-highlight-component.mdx (overlay + keyboard interaction)
  • Link from add-product-tour-react-19.mdx (React 19 compatibility angle)
  • Link to tour-progress-persistence-localstorage.mdx (next steps reference)

Distribution checklist:

  • Dev.to cross-post with canonical URL to tourkit.dev
  • Hashnode cross-post with canonical URL
  • Reddit r/reactjs as "Keyboard navigation in product tours โ€” the gotchas" self-post
  • Answer Stack Overflow questions tagged [react] + [accessibility] + [keyboard-navigation]

Ready to try userTourKit?

$ pnpm add @tour-kit/react