DEV Community

Eder Christian
Eder Christian

Posted on

Implementing Dark Mode in a Large-Scale React App

Introduction

I’ve always struggled with brightness. As someone with astigmatism, harsh white backgrounds strain my eyes. So Dark Mode isn’t just a trend for me. It’s a necessity. And I know I’m not alone: many users now expect dark themes by default.

Over the past years, platforms like GitHub, Slack, and even mobile OSes adopted Dark Mode, not just for aesthetics, but for usability in low-light environments, reduced glare, and long-session comfort. There’s growing research suggesting dark mode can improve readability and reduce eye strain in certain contexts.

At Automatiq (a New York–based company powering the secondary ticket market with automation, pricing intelligence, and distribution tools), we’ve been refactoring our design system and architecture for years. With our new platform (v2) on the horizon, it was time to finally bring Dark Mode to life. A feature both our users expected and our team needed.

Automatiq Orders page displayed in Dark Mode, showing table rows, filters, and navigation elements on a dark background.

Orders page in Dark Mode (development build)

Automatiq Orders page displayed in Light Mode, with the same table layout and filters shown on a light background.

Orders page in Light Mode (development build)

The Challenge

  • v1: Legacy Elixir/Phoenix application mixing server-rendered templates, React with Redux, and jQuery for older interactions. Styles were hardcoded and scattered across different systems. Since we're gradually migrating all old pages to the new UI, I didn't modify v1 directly.
  • v2: Modern React application with a robust architecture. TypeScript for type safety, Stitches for CSS-in-JS, TanStack Query (v5) for data fetching, React Hook Form with Zod validation, and a comprehensive component library with 100+ reusable components. Despite this solid foundation, it wasn't built with theming in mind. Components had colors directly referencing our design tokens without a theme layer.
  • It wasn't just about swapping colors. We needed accessibility (WCAG compliance across both themes), performance (instant switching without flicker), and consistency (maintaining the same experience across 100+ components and dozens of pages).

The real challenge was adding a theme system to an app already in heavy use, without breaking things or slowing it down.

Key points about V2 architecture:

  • Modern stack but not theme-ready.
  • Well-structured with design tokens already in place (neutral900, brand700, etc.).
  • Component-based architecture made it easier to implement systematically.
  • Production application with real users, so changes needed to be seamless.

Approach

Starting as a Side Project

For my team, Automatiq runs two-week sprints, and Dark Mode wasn't on our board. As someone aiming to become a Product-minded Software Engineer, I decided to build it during my personal time (about an hour at night, two hours on weekends). Not overtime, but investment in understanding our architecture deeper.

Technical Stack: Stitches + React Context

Our stack includes Stitches, a CSS-in-JS library that makes theming straightforward while keeping runtime costs at zero. This was perfect for Dark Mode because Stitches allows creating theme variants that compile to CSS classes, making theme switching instant.

Creating the Theme Structure

Instead of CSS variables, I leveraged Stitches' createTheme API to build a comprehensive color system:

// Light theme (default)
const lightTheme = {
  colors: {
    neutral900: '#102A43',  // Primary text
    neutral1000: '#F0F4F8', // Background
    brand700: '#00A4F0',    // Primary brand
    success400: '#30A46C',  // Success states
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode
// Dark theme using createTheme
const darkTheme = createTheme({
  colors: {
    neutral900: '#E9EFF5',   // Inverted for readability
    neutral1000: '#182935',  // Dark background
    brand700: '#0078BB',     // Adjusted for contrast
    success400: '#30A46C99', // With opacity for dark bg
    // ... 
  }
})
Enter fullscreen mode Exit fullscreen mode

We had been using a color naming convention in our Figma design system for years. Nothing fancy, just a set of semantic tokens the team relied on. That consistency paid off. Instead of rewriting colors everywhere, adding Dark Mode mostly came down to remapping tokens.

Theme Provider with Local Storage

For switching, I built a React Context provider that handles the theme state and remembers the user’s choice in local storage.

const ThemeProvider = ({ children }: { children: ReactNode }) => {
  // Custom hook using Zod for validation
  const [themePreference, setThemePreference] =
    useLocalStorageState<ThemePreference>(
      THEME_STORAGE_KEY,
      themePreferenceSchema,
      'light',
  )

  const isDark = themePreference === 'dark'
  const theme = isDark ? darkTheme : lightTheme

  useEffect(() => {
    // Apply theme class to body for Stitches
    document.body.classList.remove(lightTheme, darkTheme)
    document.body.classList.add(theme)

    return () => {
      document.body.classList.remove(lightTheme, darkTheme)
    }
  }, [theme])

  const toggleTheme = () => {
    setThemePreference(isDark ? 'light' : 'dark')
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Safe Context Pattern

Following our codebase patterns, I used our createSafeContext utility that provides type-safe context with proper error boundaries:

const [ThemeContext, useTheme] = createSafeContext<ThemeContextType>('theme')
Enter fullscreen mode Exit fullscreen mode

This ensures components can't accidentally use the theme context outside their provider, preventing runtime errors.

Component Integration

The beauty of Stitches is that existing styled components automatically respond to theme changes:

const StyledButton = styled('button', {
  background: theme.colors.brand700,
  color: theme.colors.neutralWhite,

  '&:hover': {
    background: theme.colors.brand800,
  }
})
Enter fullscreen mode Exit fullscreen mode

When the theme switches, Stitches automatically applies the correct color values from the active theme.

For a few components needing theme awareness, I created conditional styling:

const Header = (props) => {
  const { theme } = useTheme()
  const isDarkTheme = theme === darkTheme

  return (
    <StyledHeader
      css={{
        background: isDarkTheme 
      ? theme.colors.neutral1100 
      : theme.colors.brand1200
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

The UI Toggle Implementation and Evolution

Initially, I placed the theme toggle as a button directly in the navigation bar, protected by a feature flag:

{featureFlags.my_awesome_feature_flag ? (
  <StyledButton
    size='small'
    colorScheme='secondaryLight'
    onClick={toggleTheme}
  >
    <FontAwesomeIcon
      icon={isDark ? faSun : faMoon}
      size='lg'
    />
  </StyledButton>
) : null}
Enter fullscreen mode Exit fullscreen mode

Two months after launch, we moved it to the user menu dropdown to reduce navbar clutter and create a cleaner UI:

// Current implementation in user menu
<MenuItem
  id='user_menu_theme_toggle'
  onSelect={(e) => {
    e.preventDefault()
    toggleTheme()
  }}
>
  <div>
    Theme
      <FontAwesomeIcon
        icon={isDarkTheme ? faSun : faMoon}
        size='lg'
      />
  </div>
</MenuItem>
Enter fullscreen mode Exit fullscreen mode

From Side Project to Team Priority

After about a month of evening work, I presented my prototype at our Design Meeting. The reaction was immediate. Our CPO revealed users had been requesting this for months.

Collaboration and Refinement

  • Accessibility First: I used Claude Code to generate WCAG AAA-compliant color palettes, ensuring the right contrast for normal text.
  • Design Polish: Giovana, our designer, refined the programmatically-generated colors to align with our brand.
  • Frontend collaboration: teammates offered feedback and reviewed PRs, ensuring the solution was robust and consistent across the app.
  • Performance: Zero runtime overhead. Stitches compiles to CSS classes.
  • Testing: Added theme context to test wrappers, ensuring all component tests pass in both themes

Impact & Results

Launched in May 2025 (initially behind a feature flag), Dark Mode reached ~12% active user adoption within 4 months.

The feedback was telling: the real proof came from a few clients using third-party browser plugins just to force dark in v1 before and after we officially shipped. That kind of demand is rare.

On metrics:

  • Zero bundle size increase (no performance hit).
  • Instant switching (no flicker).
  • Use of a single source of truth for over 50 color tokens.
  • Increase in nighttime sessions (users staying longer in dark mode).

We even presented it at the WTC (World Ticket Conference) 2025, and attendees praised the feature publicly, confirming it’s not just an internal win.

Lessons Learned

  • Dark Mode is more than a visual change. It is about accessibility, proper contrast ratios, and subtle tone adjustments rather than simply inverting colors.
  • Starting with semantic tokens is critical. Because we already had design tokens such as neutral900 and brand700, the work became a matter of mapping rather than rewriting styles from scratch.
  • Incremental rollout is effective. Launching behind a feature flag and expanding gradually allowed us to gather feedback without risking the stability of the platform.
  • User demand speaks for itself. When clients resort to browser plugins before a feature is even available, it is clear that the need is genuine.
  • Involving design early makes a difference. Programmatically generated palettes were only the starting point; our designer refined them to ensure readability and brand consistency.
  • Testing both themes thoroughly is essential. We validated every component in light and dark modes, with accessibility checks for color contrast.
  • Performance and bundle size trade-offs cannot be ignored. Using Stitches with precompiled theme classes ensured zero runtime overhead.
  • Even relatively simple additions, when aligned with user expectations, can significantly increase the perceived value of a product.
  • I should have reviewed Pendo data and NPS feedback earlier to better anticipate demand and measure the effect of the launch.

Conclusion

Even though Dark Mode started as a side experiment, it turned into a meaningful addition that improved both UX and technical maturity.

At Automatiq, we’re not just re-skinning interfaces: we’re rethinking how design and code evolve together. Dark Mode is just one step in that journey.

If you’ve built or thought about implementing theming in large-scale React apps, I’d love to hear your approach. Share your experience, challenges, or odd bugs. Let’s learn together.

Top comments (0)