Recipes
Common patterns and solutions for real-world use cases.
Dark Mode / Theming
Joyride's visual options make it easy to adapt the tour to your app's theme. Pass color values through the options prop:
import { useJoyride } from 'react-joyride';
function App() {
const isDarkMode = useTheme(); // your theme hook
const { Tour } = useJoyride({
steps,
run: true,
options: {
backgroundColor: isDarkMode ? '#333333' : '#ffffff',
textColor: isDarkMode ? '#ffffff' : '#000000',
primaryColor: isDarkMode ? '#ffffff' : '#000000',
arrowColor: isDarkMode ? '#333333' : '#ffffff',
overlayColor: isDarkMode ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)',
},
});
return <div>{Tour}</div>;
}The color options:
| Option | What it styles |
|---|---|
backgroundColor | Tooltip background |
textColor | Tooltip text |
primaryColor | Primary button and beacon |
arrowColor | Arrow fill (should match backgroundColor) |
overlayColor | Overlay backdrop |
For finer control, use the styles prop to target individual elements. See Styles for all available keys.
All demos on this site use theme-aware colors. Toggle the theme switcher in the header to see it.
Conditional / Dynamic Steps
Some steps depend on UI state — a sidebar must be open, an accordion expanded, a modal mounted, or a section switched — before their target exists or is visible.
The reflex is to drive stepIndex from a useEffect that watches the relevant app state. Don't. External stepIndex updates that don't originate from onEvent desynchronize the tour lifecycle and break overlay/keyboard handlers.
Use a before step hook instead. It runs before the step's lifecycle starts, so the state change and the wait happen in the right order:
const steps = [
{
target: '.sidebar-nav-item',
content: 'Tweak settings here.',
before: () => {
setSidebarOpen(true);
return new Promise(resolve => setTimeout(resolve, 300)); // wait for open animation
},
after: () => setSidebarOpen(false), // optional cleanup
},
];For static targets that simply mount on render, no hook is needed — targetWaitTimeout already waits for the element to appear.
The Controlled Tour demo uses before hooks to coordinate sidebar open/close animations.
Multi-Route Tours
Tours that span multiple pages or routes. The key challenge is navigating between pages while keeping the tour running.
Using before hooks (recommended)
The simplest approach — use before hooks to navigate before a step renders. Joyride waits for the target to appear via targetWaitTimeout.
import { useJoyride } from 'react-joyride';
import { useNavigate } from 'react-router-dom'; // or next/navigation, etc.
function App() {
const navigate = useNavigate();
const { Tour } = useJoyride({
continuous: true,
run: true,
steps: [
// Page A — multiple steps before navigating
{
target: '.dashboard-header',
content: 'Welcome to the dashboard.',
},
{
target: '.dashboard-stats',
content: 'Here are your stats.',
},
// Navigate to Page B before step 3
{
target: '.settings-panel',
content: 'Adjust your settings here.',
before: () => {
navigate('/settings');
return new Promise(resolve => setTimeout(resolve, 300));
},
after: () => navigate('/dashboard'), // optional: go back when done
},
// Page B — another step
{
target: '.notifications-toggle',
content: 'Enable notifications.',
},
],
});
return <div>{Tour}</div>;
}This works well when:
- You have multiple steps per page
- Navigation is straightforward (no complex back/forward logic)
- You want the tour to feel linear
Using step.data and onEvent
For complex routing — bidirectional navigation, dynamic routes, or when each step maps to a different page — store route info in step.data and handle navigation in onEvent:
import { Joyride, EVENTS, STATUS, type EventData } from 'react-joyride';
const steps = [
{
target: '#home',
content: 'Start here.',
data: { next: '/page-a' },
},
{
target: '#page-a',
content: 'Page A content.',
data: { previous: '/', next: '/page-b' },
},
{
target: '#page-b',
content: 'Page B content.',
data: { previous: '/page-a' },
},
];
function handleEvent(data: EventData) {
const { action, status, step, type } = data;
if (([STATUS.FINISHED, STATUS.SKIPPED] as string[]).includes(status)) {
return;
}
if (type === EVENTS.STEP_AFTER) {
const route = action === 'prev' ? step.data?.previous : step.data?.next;
if (route) {
navigate(route);
}
}
}Joyride automatically waits for the target element to appear in the DOM (controlled by targetWaitTimeout, default 1000ms). You don't need to manually poll or add extra delays for route transitions.
The Multi Route demo shows the step.data pattern with dynamic step injection.
Modals & Portals
When tour steps target elements inside modals, the tooltip may render behind the modal due to z-index stacking. Use portalElement to render the tooltip outside the modal's DOM hierarchy.
Setup
Create a portal container at the root of your app:
// In your layout or root component
<div id="joyride-portal" />Then pass it to Joyride:
import { useJoyride } from 'react-joyride';
const { Tour } = useJoyride({
portalElement: '#joyride-portal',
steps: [
{
target: '.modal-input',
content: 'Fill in this field.',
disableFocusTrap: true, // allow interaction with modal inputs
before: () => {
openModal();
return new Promise(resolve => setTimeout(resolve, 300)); // wait for animation
},
},
{
target: '.modal-table',
content: 'Review the data.',
blockTargetInteraction: true, // prevent accidental clicks
},
],
});Key options for modals
| Option | Why |
|---|---|
portalElement | Renders tooltip outside the modal's DOM tree, avoiding z-index issues |
disableFocusTrap | Allows keyboard interaction with modal form inputs (Joyride's focus trap would otherwise capture Tab) |
blockTargetInteraction | Prevents clicks on interactive elements like tables or forms during the step |
options.zIndex | Adjust if the tooltip still appears behind the modal (default: 100) |
before hook | Wait for modal open animations before showing the step |
The Modal demo shows tours inside both HeroUI and react-modal dialogs.
Restarting a Tour
Reset progress and start over from the first step in a single call. controls.reset(true) clears the index, lifecycle, and waiting flags, then sets the status to running.
import { useJoyride } from 'react-joyride';
function App() {
const { controls, Tour } = useJoyride({ continuous: true, steps });
return (
<>
<button onClick={() => controls.reset(true)}>Restart tour</button>
{Tour}
</>
);
}Pass true to relaunch immediately; omit the argument to clear progress without starting.
You can also restart from inside an event handler — onEvent receives controls as its second argument:
import { useJoyride } from 'react-joyride';
useJoyride({
steps,
onEvent: (data, controls) => {
if (data.type === 'tour:end') {
controls.reset(true);
}
},
});reset() is a no-op in controlled mode — when you pass stepIndex, the parent owns the index. Drive the restart from your own state instead.
Using the Hook in Next.js (App Router)
Both <Joyride> and useJoyride are render-safe on the server — they return null for the tour element until hydration. React Server Components still forbid hooks anywhere, so the hook itself must live behind a 'use client' boundary. Drop that client component into a server page as an island.
Tour island
// app/_components/OnboardingTour.tsx
'use client';
import { useEffect } from 'react';
import { useJoyride } from 'react-joyride';
const steps = [
{ target: '[data-tour="dashboard"]', content: 'Your dashboard.' },
{ target: '[data-tour="settings"]', content: 'Settings live here.' },
];
export default function OnboardingTour() {
const { controls, on, Tour } = useJoyride({ continuous: true, steps });
useEffect(
() => on('tour:end', () => localStorage.setItem('onboarded', '1')),
[on],
);
useEffect(() => {
if (!localStorage.getItem('onboarded')) {
controls.start();
}
}, [controls]);
return Tour;
}Server page
// app/page.tsx
import OnboardingTour from './_components/OnboardingTour';
export default function Page() {
return (
<>
<main>
<section data-tour="dashboard">...</section>
<section data-tour="settings">...</section>
</main>
<OnboardingTour />
</>
);
}The page stays a server component; only the tour ships as a client island. Targets resolve once the page hydrates.
If you don't need access to controls or on, render <Joyride> directly inside any client component — its DOM guard handles SSR automatically.