
Product tours in Electron apps: desktop onboarding guide
Desktop apps built on Electron still ship with the same onboarding problem as web apps: users open your tool, stare at a complex interface, and close it without discovering the features you spent months building. The difference is that Electron adds layers of complexity that web-only tour libraries don't account for. You have a main process that can't render tooltips, native menus that DOM overlays can't target, and multi-window layouts where a single-page tour falls apart.
Tour Kit is a headless React product tour library that runs in Electron's renderer process and gives you full control over step rendering, positioning, and state. As of April 2026, Electron powers over 8,000 apps on the Mac App Store alone (Fora Soft, 2026), yet no guide covers the specific challenges of building product tours in this environment. This tutorial fills that gap.
By the end, you'll have a working multi-step product tour in an Electron React app with offline persistence and keyboard navigation.
npm install @tourkit/core @tourkit/reactWhat you'll build
Tour Kit's renderer-process architecture gives you a 5-step product tour that highlights UI elements, handles keyboard navigation, persists progress to Electron's local storage, and works completely offline. The tour targets standard DOM elements in your BrowserWindow, no IPC required for basic flows. You'll add IPC coordination for cross-window scenarios in a later step.
The finished tour weighs under 12KB gzipped on top of your existing bundle. For an Electron app already shipping 80-150MB (Brainhub, 2025), that's a rounding error on download size.
Prerequisites
- Electron 28+ with React 18.2+ in the renderer process
- TypeScript 5.0+ configured
- An existing Electron app scaffolded with Electron Forge or a similar tool
- Context Isolation enabled (the default since Electron 12)
- Basic familiarity with Electron's main/renderer process split
Step 1: install Tour Kit in your renderer
Tour Kit runs entirely in Electron's renderer process, which means it never touches Node.js APIs and works with Context Isolation and nodeIntegration: false out of the box. No preload script modifications are needed because the library operates on standard DOM APIs that Chromium provides to every renderer.
// src/renderer/App.tsx
import { TourProvider } from '@tourkit/react';
function App() {
return (
<TourProvider>
<MainWindow />
</TourProvider>
);
}
export default App;The TourProvider wraps your renderer's React tree exactly like it would in a web app. Because Electron's renderer is Chromium, every React pattern works identically.
One thing to watch: if you're using Electron Forge with webpack, make sure @tourkit/core and @tourkit/react aren't being bundled as externals. They need to ship in your renderer bundle, not get resolved from node_modules at runtime.
Step 2: define your tour steps
Each step targets a DOM element in your renderer's HTML. Use CSS selectors or refs (both work). For Electron apps, we recommend data-tour attributes because they survive component refactors better than class-based selectors.
// src/renderer/tours/welcome-tour.ts
import type { TourStep } from '@tourkit/core';
export const welcomeTour: TourStep[] = [
{
id: 'sidebar-nav',
target: '[data-tour="sidebar"]',
title: 'Navigation',
content: 'Your projects, settings, and recent files live here.',
},
{
id: 'editor-panel',
target: '[data-tour="editor"]',
title: 'Editor',
content: 'This is where you work. Drag files from the sidebar to open them.',
},
{
id: 'toolbar',
target: '[data-tour="toolbar"]',
title: 'Quick actions',
content: 'Run, debug, and deploy from the toolbar. Keyboard shortcuts are shown on hover.',
},
{
id: 'status-bar',
target: '[data-tour="status-bar"]',
title: 'Status bar',
content: 'Connection status, sync progress, and notifications appear here.',
},
{
id: 'settings-gear',
target: '[data-tour="settings"]',
title: 'Settings',
content: 'Customize themes, keybindings, and integrations.',
},
];Five steps is the sweet spot for a first-run tour. As the Appcues team puts it: "You cannot onboard someone you do not understand. No amount of beautiful illustrations, perfectly laid out screens or tooltips will make up for not clearly understanding users" (Appcues, 2026). Keep it focused.
Step 3: launch the tour on first run
Electron apps don't have URL-based routing the way web apps do, so you can't trigger tours based on a URL path. Instead, detect first-run status using Electron's electron-store or app.getPath('userData') and pass that signal to your renderer via the preload script.
// src/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
isFirstRun: () => ipcRenderer.invoke('check-first-run'),
markTourComplete: (tourId: string) =>
ipcRenderer.invoke('mark-tour-complete', tourId),
isTourComplete: (tourId: string) =>
ipcRenderer.invoke('is-tour-complete', tourId),
});// src/main/tour-state.ts
import Store from 'electron-store';
const store = new Store<{ completedTours: string[] }>({
defaults: { completedTours: [] },
});
export function isFirstRun(): boolean {
return store.get('completedTours').length === 0;
}
export function markTourComplete(tourId: string): void {
const completed = store.get('completedTours');
if (!completed.includes(tourId)) {
store.set('completedTours', [...completed, tourId]);
}
}
export function isTourComplete(tourId: string): boolean {
return store.get('completedTours').includes(tourId);
}Register the IPC handlers in your main process:
// src/main/index.ts
import { ipcMain } from 'electron';
import { isFirstRun, markTourComplete, isTourComplete } from './tour-state';
ipcMain.handle('check-first-run', () => isFirstRun());
ipcMain.handle('mark-tour-complete', (_, tourId: string) =>
markTourComplete(tourId)
);
ipcMain.handle('is-tour-complete', (_, tourId: string) =>
isTourComplete(tourId)
);Now wire it up in your renderer component:
// src/renderer/components/WelcomeTour.tsx
import { useTour } from '@tourkit/react';
import { useEffect, useState } from 'react';
import { welcomeTour } from '../tours/welcome-tour';
export function WelcomeTour() {
const { start, isActive } = useTour();
const [checked, setChecked] = useState(false);
useEffect(() => {
async function checkFirstRun() {
const firstRun = await window.electronAPI.isFirstRun();
if (firstRun) {
start({ steps: welcomeTour, id: 'welcome' });
}
setChecked(true);
}
checkFirstRun();
}, [start]);
if (!checked || !isActive) return null;
// Tour Kit renders the overlay; your custom step UI goes here
return null;
}This approach stores tour completion state in Electron's userData directory, which persists across app updates and works offline. No server round-trips, no localStorage limitations.
Step 4: add keyboard navigation and accessibility
Electron's accessibility docs note that "accessibility concerns in Electron applications are similar to those of websites because they're both ultimately HTML" (Electron Docs). Tour Kit handles ARIA attributes, focus trapping, and keyboard navigation by default. But Electron adds a nuance: keyboard shortcuts.
Your app likely binds global shortcuts via globalShortcut.register() in the main process. These can conflict with tour navigation keys (Escape to close, arrow keys to navigate). Disable conflicting shortcuts while the tour is active:
// src/renderer/components/WelcomeTour.tsx
import { useTour } from '@tourkit/react';
import { useEffect } from 'react';
export function useTourShortcutGuard() {
const { isActive } = useTour();
useEffect(() => {
if (isActive) {
window.electronAPI.pauseGlobalShortcuts();
} else {
window.electronAPI.resumeGlobalShortcuts();
}
}, [isActive]);
}Add the corresponding preload and main-process handlers to temporarily unregister shortcuts while the tour runs.
Platform-specific keyboard instructions matter too. Tour Kit's step content renders whatever JSX you provide, so detect the platform and adjust:
// src/renderer/lib/platform.ts
export function modKey(): string {
return navigator.platform.includes('Mac') ? '⌘' : 'Ctrl';
}
// In a step:
// content: `Press ${modKey()}+K to open the command palette.`Screen readers work in Electron because Chromium exposes the accessibility tree natively. On macOS, VoiceOver picks up ARIA live regions automatically. On Windows, JAWS and NVDA work through Chromium's accessibility API. Electron even auto-enables accessibility features when it detects assistive technology running (Electron Accessibility Docs).
Step 5: handle Electron-specific edge cases
Desktop product tours in Electron hit three problems that web-only tours never encounter: native OS menus that exist outside the DOM, multi-window layouts that span separate renderer processes, and offline operation where CDN-hosted tour assets aren't available. Here's how to handle each one.
Native menus can't be targeted
Electron's Menu and MenuItem render through the operating system, not the DOM. A DOM overlay physically cannot highlight a native menu item. The workaround: point users to the general area and describe the action textually.
{
id: 'menu-hint',
target: '[data-tour="titlebar"]',
title: 'File menu',
content: 'Click File > New Project to create your first project. The menu bar above this window has all available actions.',
placement: 'bottom',
}For apps using custom title bars (rendered in HTML), this limitation disappears entirely. You get full DOM access to every menu element.
Multi-window coordination
If your app opens a secondary BrowserWindow (settings panel, preview window), a single TourProvider can't span both windows. Each BrowserWindow has its own renderer process and React tree.
The pattern: use IPC to synchronize tour state between windows.
// In the main process: broadcast tour events to all windows
import { BrowserWindow } from 'electron';
export function broadcastTourEvent(event: string, data: unknown) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send('tour-event', { event, data });
}
}Each window's renderer listens for tour-event and updates its local TourProvider accordingly. This keeps tours coordinated without sharing React context across process boundaries.
Offline-first tour assets
Unlike web apps, Electron tours should never fetch assets from a CDN at runtime. Bundle tour images and animations with your app. If you're using Tour Kit's @tourkit/media package for embedded videos, fall back to local assets when navigator.onLine returns false:
const videoSrc = navigator.onLine
? 'https://cdn.example.com/tour-intro.mp4'
: new URL('../assets/tour-intro.mp4', import.meta.url).href;Performance considerations
Adding a tour library to an Electron app that already ships 80-150MB of Chromium and Node.js barely moves the needle on download size, but it can affect startup time if you load tour code eagerly on the critical path. Electron's own performance guide puts it plainly: "The most successful strategy for building a performant Electron app is to profile the running code, find the most resource-hungry piece of it, and to [improve] it" (Electron Perf Docs). Tour libraries are no exception.
Tour Kit's core ships under 8KB gzipped. For context, the request module takes ~500ms to load in Electron while node-fetch loads in under 50ms (Electron Perf Docs). Tour Kit loads in the renderer process and doesn't touch Node.js modules at all, so it avoids the heavy module resolution path entirely.
Lazy-load the tour component to keep it off the critical startup path:
// src/renderer/App.tsx
import { lazy, Suspense } from 'react';
const WelcomeTour = lazy(() => import('./components/WelcomeTour'));
function App() {
return (
<TourProvider>
<MainWindow />
<Suspense fallback={null}>
<WelcomeTour />
</Suspense>
</TourProvider>
);
}| Library | Bundle size (gzipped) | Tree-shakeable | Electron-specific docs | Offline support |
|---|---|---|---|---|
| Tour Kit | <12KB (core + react) | Yes | This guide | Full (local state) |
| React Joyride | ~37KB | No | None | Partial (localStorage) |
| Shepherd.js | ~28KB | Partial | None | Partial (localStorage) |
| Driver.js | ~5KB | Yes | None | Partial (localStorage) |
| Reactour | ~15KB + styled-components | No | None | Partial (localStorage) |
Tour Kit is the only library in this comparison with Electron-specific documentation and full offline persistence through Electron's userData storage. We built Tour Kit, so take this comparison with appropriate skepticism — verify bundle sizes on bundlephobia yourself.
One limitation to acknowledge: Tour Kit requires React 18+ and doesn't have a mobile SDK. If your Electron app also ships a React Native companion, you'll need a separate onboarding solution for mobile.
Common issues and troubleshooting
We tested Tour Kit in Electron 34+ across Windows 11, macOS Sonoma, and Ubuntu 24.04 and ran into four recurring issues that are specific to the desktop environment. Each one has a direct fix.
"Tour tooltip doesn't appear on app launch"
The DOM elements targeted by your tour steps may not exist when the tour starts. Electron apps often render complex layouts asynchronously. Tour Kit waits for target elements by default, but if you're using lazy-loaded panels or conditional renders, make sure the target element mounts before the tour starts:
// Wait for the main layout to mount
useEffect(() => {
const el = document.querySelector('[data-tour="sidebar"]');
if (el) start({ steps: welcomeTour, id: 'welcome' });
}, [layoutReady]);"Keyboard shortcuts conflict with tour navigation"
Escape closes the tour by default, but if your app binds Escape to close a panel or modal, both handlers fire. Use the shortcut guard pattern from Step 4 to temporarily disable your app's shortcuts while the tour is active.
"Content Security Policy blocks tour overlay styles"
Some Electron apps use strict CSP headers. Tour Kit uses CSS classes rather than inline styles, so it works with most CSP configurations. If you're using style-src 'self' without 'unsafe-inline', verify that Tour Kit's stylesheet is loaded from your bundle, not injected at runtime.
"Tour resets after auto-update"
If you're storing tour state in localStorage, Electron's auto-updater can clear it during certain update scenarios. Use electron-store (backed by a JSON file in app.getPath('userData')) instead. The userData directory persists across updates.
Next steps
You've got a working product tour in your Electron app. From here, consider:
- Adding conditional tours based on user role for power users versus first-timers
- Building a progress persistence layer that syncs across devices when online
- Using hotspot components for ongoing feature discovery after the initial tour
The Tour Kit documentation covers advanced patterns like multi-tour orchestration, analytics integration, and custom step renderers.
FAQ
Can React product tour libraries work inside Electron apps?
Tour Kit, React Joyride, Shepherd.js, and Driver.js all run in Electron's renderer process because it's standard Chromium. Any DOM-based tour library works. The real challenge is Electron-specific concerns like multi-window coordination, native menu highlighting, and offline persistence. Tour Kit handles these through IPC patterns and Electron's local storage APIs.
Does adding a product tour slow down an Electron app?
Tour Kit's core bundle is under 8KB gzipped, which adds negligible weight to an Electron app already shipping 80-150MB. The real performance risk is blocking startup. Lazy-load the tour component with React.lazy() and defer tour initialization until after your app's critical UI renders. Profile with Electron's built-in DevTools (Chromium Performance tab) to verify zero startup impact.
How do you persist tour progress in Electron without a server?
Use electron-store — a simple key-value store backed by a JSON file in Electron's app.getPath('userData') directory. Unlike browser localStorage, this persists across auto-updates and works completely offline. Tour Kit's storage adapter pattern lets you swap the persistence backend without changing your tour logic.
What about onboarding tours for features behind native OS menus?
Native menus rendered by the operating system (Electron's Menu API) live outside the DOM. No web-based tour library can overlay a tooltip on a native menu item. The practical solution is to point to the general title bar area and describe the menu action in text, or switch to a custom HTML-rendered title bar where you get full DOM access to every menu element.
How is Tour Kit different from React Joyride for Electron apps?
React Joyride ships at approximately 37KB gzipped and uses opinionated UI components that are difficult to match to custom design systems. Tour Kit is headless (under 12KB gzipped), so you render steps with your own JSX and style them however you want. For Electron specifically, Tour Kit's architecture supports IPC-based cross-window tour coordination and electron-store persistence — patterns that React Joyride doesn't address in its documentation.
Related articles

Amplitude + Tour Kit: measuring onboarding impact on retention
Wire Tour Kit callbacks to Amplitude track() for onboarding funnels, behavioral cohorts, and retention analysis. TypeScript examples included.
Read article
How to add a product tour to an Astro site with React islands
Add interactive product tours to an Astro site using React islands. Covers client directives, Nanostores state sharing, and Tour Kit setup.
Read article
Building conditional product tours based on user role
Build role-based product tours in React with Tour Kit. Filter steps by admin, editor, or viewer roles using the when prop and React Context.
Read article
Using CSS container queries for responsive product tours
Build product tour tooltips that adapt to their container, not the viewport. Learn CSS container queries with Tour Kit for truly responsive onboarding.
Read article