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.
Orders page in Dark Mode (development build)
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
// ...
}
}
// 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
// ...
}
})
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>
)
}
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')
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,
}
})
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
}}
/>
)
}
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}
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>
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
andbrand700
, 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)