Skip to main content

Theming product tours for dark mode in React

Build dark-mode-aware product tours in React with CSS variables, adaptive overlays, and WCAG-compliant contrast. Includes working TypeScript examples.

DomiDex
DomiDexCreator of Tour Kit
April 7, 202610 min read
Share
Theming product tours for dark mode in React

Theming product tours for dark mode in React

Your app has dark mode. Your design system toggles flawlessly between light and dark. Then you add a product tour and the whole thing falls apart. Bright white tooltips flash against a dark UI, the spotlight overlay vanishes into the dark background, and accent colors bleed on low-contrast surfaces.

Most React tour libraries treat dark mode as an afterthought. They ship with hardcoded light-theme styles and leave you to override everything yourself. Tour Kit takes a different approach: you define CSS variables for your tour theme, and every component (tooltips, overlays, progress indicators) respects them automatically. No forked stylesheets, no conditional class gymnastics.

By the end of this tutorial you'll have a product tour that adapts to light mode, dark mode, and system preferences with zero conditional rendering logic.

npm install @tourkit/core @tourkit/react

What you'll build

A 3-step product tour with a tooltip, overlay, and progress bar that switches between light and dark themes automatically. The tour reads the user's system preference via prefers-color-scheme, applies the correct CSS variables, and lets you toggle themes mid-tour without any re-render. All colors pass WCAG AA contrast requirements in both modes.

Prerequisites

  • React 18.2+ or React 19
  • TypeScript 5.0+
  • A working dark mode toggle in your app (Tailwind dark: class, data-theme attribute, or prefers-color-scheme media query)
  • Basic familiarity with CSS custom properties

What makes dark mode hard for product tours

Product tours layer multiple surfaces on top of your UI (overlays, tooltips, highlights, progress bars), and each one interacts differently with background color. A dark mode product tour hits at least three problems that regular component theming never encounters because tours combine transparent overlays, floating surfaces, and accent-colored controls in a single view. Most apps treat these as independent styling concerns, but in a product tour they stack on top of each other and the underlying page simultaneously.

The overlay problem is the most visible. Tour libraries typically render a semi-transparent black backdrop (rgba(0, 0, 0, 0.5)) to dim the page and draw attention to the highlighted element. On a light UI, that works. On a dark UI, the overlay is nearly invisible because you're layering dark transparency over an already-dark background. The spotlight cutout that's supposed to draw attention loses all contrast.

Tooltip elevation breaks next. In light mode, tooltips float above the page with box shadows. In dark mode, Steve Schoger's elevation principle applies: closer surfaces should be lighter, not darker (CSS-Tricks). A tooltip rendered at the same dark shade as the page background looks flat and illegible.

Accent color vibration is subtler. Saturated brand colors (think #0033cc or #e60000) that look crisp on white backgrounds will visually vibrate or bleed on dark surfaces. Dark themes need desaturated variants: #809fff instead of #0033cc.

Step 1: define CSS variables for your tour theme

CSS custom properties let you define your tour's entire color palette once and switch between light and dark themes by overriding a single selector. This approach avoids writing duplicate stylesheets and keeps your tour's visual identity centralized in one place where designers and developers can both reason about it (CSS-Tricks).

/* src/styles/tour-theme.css */
:root {
  --tour-bg: #ffffff;
  --tour-text: #1a1a2e;
  --tour-overlay: rgba(0, 0, 0, 0.5);
  --tour-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  --tour-accent: #0033cc;
  --tour-accent-text: #ffffff;
  --tour-border: #e2e8f0;
  --tour-progress-bg: #e2e8f0;
  --tour-progress-fill: #0033cc;
}

[data-theme='dark'] {
  --tour-bg: #1e1e2e;
  --tour-text: #e2e8f0;
  --tour-overlay: rgba(0, 0, 0, 0.75);
  --tour-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
  --tour-accent: #809fff;
  --tour-accent-text: #1a1a2e;
  --tour-border: #2d2d3f;
  --tour-progress-bg: #2d2d3f;
  --tour-progress-fill: #809fff;
}

Two things to notice. First, the dark overlay opacity jumps from 0.5 to 0.75, and that extra contrast is what makes the spotlight cutout visible on a dark background. Second, the tooltip background (--tour-bg) in dark mode uses #1e1e2e, not pure black. Pure black (#000000) causes a halo effect around light text and contributes to eye strain (Smashing Magazine).

If your app uses Tailwind's dark: class strategy instead of data-theme, swap the selector:

/* Tailwind dark mode variant */
.dark {
  --tour-bg: #1e1e2e;
  --tour-text: #e2e8f0;
  /* ... same overrides ... */
}

Step 2: build a theme-aware tooltip component

Because Tour Kit is headless, it handles step sequencing, element highlighting, and scroll management while you render the tooltip UI yourself. Your tooltip component consumes the CSS variables directly, which means a single component works across both light and dark themes without any conditional logic or theme-aware props passed down from a parent.

// src/components/TourTooltip.tsx
import { useTour } from '@tourkit/react';

interface TourTooltipProps {
  title: string;
  content: string;
}

export function TourTooltip({ title, content }: TourTooltipProps) {
  const { currentStep, totalSteps, next, prev, stop } = useTour();

  return (
    <div
      role="dialog"
      aria-label={title}
      style={{
        backgroundColor: 'var(--tour-bg)',
        color: 'var(--tour-text)',
        border: '1px solid var(--tour-border)',
        boxShadow: 'var(--tour-shadow)',
        borderRadius: '8px',
        padding: '16px',
        maxWidth: '320px',
      }}
    >
      <h3 style={{ margin: '0 0 8px', fontSize: '16px' }}>{title}</h3>
      <p style={{ margin: '0 0 16px', fontSize: '14px', lineHeight: 1.5 }}>
        {content}
      </p>

      {/* Progress bar */}
      <div
        style={{
          height: '4px',
          backgroundColor: 'var(--tour-progress-bg)',
          borderRadius: '2px',
          marginBottom: '12px',
        }}
      >
        <div
          style={{
            width: `${((currentStep + 1) / totalSteps) * 100}%`,
            height: '100%',
            backgroundColor: 'var(--tour-progress-fill)',
            borderRadius: '2px',
            transition: 'width 200ms ease',
          }}
        />
      </div>

      {/* Navigation */}
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <button
          onClick={prev}
          disabled={currentStep === 0}
          style={{
            background: 'none',
            border: '1px solid var(--tour-border)',
            color: 'var(--tour-text)',
            padding: '6px 12px',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          Back
        </button>
        <button
          onClick={currentStep === totalSteps - 1 ? stop : next}
          style={{
            backgroundColor: 'var(--tour-accent)',
            color: 'var(--tour-accent-text)',
            border: 'none',
            padding: '6px 12px',
            borderRadius: '4px',
            cursor: 'pointer',
          }}
        >
          {currentStep === totalSteps - 1 ? 'Done' : 'Next'}
        </button>
      </div>
    </div>
  );
}

Every color references a CSS variable. When the user toggles their theme, the tooltip repaints instantly with no React re-render. This is the performance advantage of CSS-variable theming over state-driven conditional styles. The browser handles the swap at the style layer.

Step 3: handle the overlay opacity problem

The overlay is the single most common failure point for dark mode product tours, because a semi-transparent black layer that dims a light page becomes nearly invisible against an already-dark background. The fix: bump the overlay opacity in dark mode through the same CSS variables you already defined.

// src/components/TourOverlay.tsx
import type { CSSProperties } from 'react';

interface TourOverlayProps {
  children: React.ReactNode;
}

export function TourOverlay({ children }: TourOverlayProps) {
  const overlayStyle: CSSProperties = {
    position: 'fixed',
    inset: 0,
    backgroundColor: 'var(--tour-overlay)',
    zIndex: 9998,
    transition: 'background-color 200ms ease',
  };

  return (
    <div style={overlayStyle} aria-hidden="true">
      {children}
    </div>
  );
}

We already defined --tour-overlay as rgba(0, 0, 0, 0.5) in light mode and rgba(0, 0, 0, 0.75) in dark mode. That 25-percentage-point bump is enough to make the spotlight cutout pop against dark backgrounds without making light-mode overlays feel oppressive.

The transition on background-color smooths out theme switches. Without it, the overlay snaps from one opacity to another when a user toggles dark mode mid-tour.

Step 4: respect system preferences automatically

About 70% of developers use dark mode by default (2020 developer survey, widely cited), and many of them rely on their operating system's automatic scheduling rather than toggling manually. Your tour needs to detect the system preference on load and react to changes in real time, while still allowing a manual override for users who want something different from their OS default.

// src/hooks/useTourTheme.ts
import { useEffect, useState, useCallback } from 'react';

type Theme = 'light' | 'dark' | 'system';

export function useTourTheme(initialTheme: Theme = 'system') {
  const [theme, setTheme] = useState<Theme>(initialTheme);
  const [resolved, setResolved] = useState<'light' | 'dark'>('light');

  useEffect(() => {
    if (theme !== 'system') {
      setResolved(theme);
      return;
    }

    const mq = window.matchMedia('(prefers-color-scheme: dark)');
    setResolved(mq.matches ? 'dark' : 'light');

    const handler = (e: MediaQueryListEvent) => {
      setResolved(e.matches ? 'dark' : 'light');
    };
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, [theme]);

  const toggle = useCallback(() => {
    setTheme((prev) => {
      if (prev === 'light') return 'dark';
      if (prev === 'dark') return 'system';
      return 'light';
    });
  }, []);

  return { theme, resolved, setTheme, toggle };
}

The hook resolves 'system' into 'light' or 'dark' by reading the prefers-color-scheme media query. It also listens for changes, so if the user's OS switches to dark mode at sunset, the tour updates in real time.

Wire it into your app's root:

// src/App.tsx
import { useTourTheme } from './hooks/useTourTheme';

function App() {
  const { resolved } = useTourTheme('system');

  return (
    <div data-theme={resolved}>
      {/* Your app + tour components */}
    </div>
  );
}

Bits and Pieces documented a similar pattern for syncing React apps with the system color scheme (blog.bitsrc.io). The key difference here is the three-state toggle (light, dark, system) instead of a binary switch.

Step 5: verify contrast ratios

Dark mode doesn't exempt you from WCAG 2.1 Success Criterion 1.4.3. Normal text needs a 4.5:1 contrast ratio. Large text (18px+ or 14px bold) needs 3:1. No exceptions for optional dark mode toggles (BOIA).

Here are the contrast ratios for the tour variables we defined:

PairingLight modeDark modeWCAG AA
Text on background#1a1a2e on #ffffff = 16.5:1#e2e8f0 on #1e1e2e = 12.3:1Pass (4.5:1)
Accent text on accent#ffffff on #0033cc = 8.9:1#1a1a2e on #809fff = 6.2:1Pass (4.5:1)
Border on background#e2e8f0 on #ffffff = 1.3:1#2d2d3f on #1e1e2e = 1.4:1N/A (decorative)

We tested these pairings with the WebAIM contrast checker. Both themes pass AA for all text content. The borders are decorative, so they don't need to meet the ratio, but if you use borders to convey meaning (like error states), verify those too.

One gotcha we hit: the dark accent color #809fff on a #1e1e2e background has a 6.8:1 ratio, which is fine. But if you use it as small body text on a #2d2d3f surface (like inside a progress bar label), the ratio drops to 4.1:1, below the 4.5:1 threshold. Check every surface combination, not just the primary pairs.

Step 6: put it all together

Here's the complete setup with Tour Kit, dark mode theming, and system preference detection working together:

// src/components/ProductTour.tsx
import { TourProvider, TourStep } from '@tourkit/react';
import { TourTooltip } from './TourTooltip';
import { useTourTheme } from '../hooks/useTourTheme';

const steps: TourStep[] = [
  {
    target: '#dashboard-header',
    title: 'Welcome to your dashboard',
    content: 'This is where you track key metrics at a glance.',
  },
  {
    target: '#sidebar-nav',
    title: 'Navigation',
    content: 'Use the sidebar to switch between sections.',
  },
  {
    target: '#quick-actions',
    title: 'Quick actions',
    content: 'Common tasks are always one click away.',
  },
];

export function ProductTour() {
  const { resolved } = useTourTheme('system');

  return (
    <div data-theme={resolved}>
      <TourProvider
        steps={steps}
        renderTooltip={({ step }) => (
          <TourTooltip title={step.title} content={step.content} />
        )}
      >
        {/* Your app content */}
      </TourProvider>
    </div>
  );
}

Import the CSS file from Step 1 in your app's entry point:

// src/main.tsx
import './styles/tour-theme.css';

When the tour launches, it reads the current data-theme attribute and applies the correct CSS variable set. Toggle your theme mid-tour and the tooltip, overlay, and progress bar all update instantly. No state lifting, no re-render cascade, no prop drilling.

Common issues and troubleshooting

We hit each of these problems while building dark-mode-aware tours in test apps running Vite 6, React 19, and Tailwind 4. The fixes below are the patterns that actually resolved the issue, not guesses from documentation.

"My overlay disappears on dark backgrounds"

This happens when the overlay opacity is too low for dark themes. Check your --tour-overlay value. It should be rgba(0, 0, 0, 0.7) or higher for dark mode. If you're using a single fixed value, add the [data-theme='dark'] override from Step 1.

"Colors look washed out after toggling themes"

The browser sometimes caches the previous variable values during a transition. Add transition: color 200ms ease, background-color 200ms ease to your tooltip container. This smooths the switch and prevents a frame where old and new values mix.

"Tour flashes light theme before dark mode loads"

This is the flash of incorrect theme (FOIT). It happens when CSS loads before JavaScript sets the data-theme attribute. Fix it by injecting a blocking script in <head> that reads the preference from localStorage or prefers-color-scheme before the first paint:

<script>
  const theme = localStorage.getItem('theme') ||
    (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
  document.documentElement.setAttribute('data-theme', theme);
</script>

This runs synchronously before CSS applies, eliminating the flash.

"Accent colors vibrate on dark backgrounds"

Saturated colors cause optical vibration on dark surfaces. Desaturate your accent by shifting toward pastels in dark mode. Going from #0033cc to #809fff drops the saturation while keeping the hue recognizable. HSL makes this explicit: hsl(220, 100%, 40%) becomes hsl(220, 60%, 75%).

Next steps

You've got a dark-mode-aware tour running. From here you can:

  • Add a high contrast mode by defining a third theme with even stronger contrast ratios (7:1+ for AAA compliance)
  • Use the CSS light-dark() function to reduce your variable definitions (supported in Chrome 123+, Firefox 120+, and Safari 17.5+) (CSS-Tricks)
  • Integrate with your existing theme provider (next-themes, shadcn/ui's theme system) by reading its context instead of managing your own
  • Add transition animations that respect prefers-reduced-motion (see our reduced motion guide)

Tour Kit doesn't prescribe your theming approach. Whether you use CSS variables, Tailwind's dark: variant, CSS-in-JS, or the new light-dark() function, the headless architecture means your tour inherits whatever system you already have. No overrides to fight.

One limitation worth noting: Tour Kit requires React 18+ and doesn't include a visual builder. You'll write the tour configuration and tooltip components in code. For teams already comfortable with React and TypeScript, that's a feature. For teams expecting a drag-and-drop editor, it's a consideration.

FAQ

Does Tour Kit have built-in dark mode support?

Tour Kit is headless, so it doesn't ship with pre-styled themes for light or dark mode. You control the rendering entirely. Your tour inherits your app's existing dark mode system through CSS variables or Tailwind's dark: variant, with no library-specific overrides to maintain.

What contrast ratio do tour tooltips need in dark mode?

WCAG 2.1 Success Criterion 1.4.3 requires a minimum 4.5:1 contrast ratio for normal text and 3:1 for large text (18px or 14px bold). These requirements apply equally to dark mode, with no exception for optional theme toggles. Test every pairing in both themes with WebAIM's contrast checker.

Why does my tour overlay disappear in dark mode?

Tour overlays typically use a semi-transparent black layer to dim the page. On dark backgrounds, rgba(0, 0, 0, 0.5) is barely visible because the underlying surface is already dark. Increase the dark-mode overlay opacity to 0.7 or higher. With CSS variables, define separate --tour-overlay values for light and dark themes.

How do I prevent the flash of light theme when loading a tour?

The flash occurs because JavaScript sets the theme attribute after CSS renders the default (light) styles. Add a synchronous <script> block in your <head> tag that reads the user's saved preference from localStorage or prefers-color-scheme and sets the data-theme attribute before the first paint.

Can I use Tailwind's dark mode with Tour Kit?

Yes. Define your CSS variables under the .dark class selector instead of [data-theme='dark']. Tour Kit's headless components pick up whatever variable values are active in the current scope. You can also use Tailwind's dark: variant directly on tooltip JSX.


Ready to try userTourKit?

$ pnpm add @tour-kit/react