Tailwind CSS + Storybook Setup: Preview Config, Theme Switcher
Wire Tailwind CSS into Storybook 8 properly — preview.ts config, dark mode class toggling, and a working theme switcher toolbar addon.
Why This Combo Is Annoying to Set Up (But Worth It)
Tailwind and Storybook should be best friends. You're building a component library, you want to see those components in isolation, and you want dark mode to actually work when you flip the toggle. In theory, ten minutes of config. In practice, it's an afternoon of chasing why your bg-gray-900 shows up white in the canvas.
The problem is that Storybook renders stories inside an iframe with its own document. Your Tailwind styles compile fine, but the dark class strategy — which Tailwind applies to <html> — doesn't automatically carry over into that iframe. So you get a "dark" story that's blinding white. Fun.
This guide covers Storybook 8.x with Tailwind v3 and v4. If you're still on Storybook 7, most of this works but the withThemeByClassName decorator API changed slightly in 8.0. Worth noting: the @storybook/addon-themes package replaced the old storybook-dark-mode addon in 2024 and it's genuinely better.
Honestly, the biggest time sink is getting the CSS import chain right. Once that's solid, the theme switcher toolbar is maybe 20 lines of config.
Installing the Dependencies
Start fresh or add to an existing project — either way, you need the same packages. Here's the baseline for a Vite-based Storybook setup:
npx storybook@latest init
npm install -D @storybook/addon-themes tailwindcss postcss autoprefixer
npx tailwindcss init -pIf you're starting from a Next.js or Vite project that already has Tailwind, skip the last two lines. The @storybook/addon-themes package is the one doing the heavy lifting for theme switching — it gives you the toolbar button and the decorator that applies class names to the story iframe's root element.
Quick aside: if you're on Tailwind v4, you don't have a tailwind.config.js anymore. The config moves into your CSS file via @import "tailwindcss" and @theme. The Storybook integration still works, but the import you add to preview.ts points to that CSS file directly, not a config file.
# Tailwind v4 only
npm install -D tailwindcss@next @tailwindcss/viteThe preview.ts Config That Actually Works
This is where most setups break. Open .storybook/preview.ts (or preview.js if you're not using TypeScript) and add your Tailwind CSS import at the very top. Storybook processes this file and injects whatever you import into the story iframe.
// .storybook/preview.ts
import '../src/styles/globals.css'; // your Tailwind entry file
import type { Preview } from '@storybook/react';
import { withThemeByClassName } from '@storybook/addon-themes';
const preview: Preview = {
decorators: [
withThemeByClassName({
themes: {
light: '',
dark: 'dark',
},
defaultTheme: 'light',
}),
],
parameters: {
backgrounds: { disable: true }, // disable default backgrounds addon, we handle this
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;The withThemeByClassName decorator is the key part. When you switch to "dark" in the toolbar, it adds the dark class to the <html> element of the story iframe. That's exactly what Tailwind's class-based dark mode needs. No hacks, no custom globals.
Make sure your globals.css (or whatever you named it) actually imports Tailwind. If you're on v3, that's three directives. If you're on v4, it's one import:
/* Tailwind v3 — src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Tailwind v4 — src/styles/globals.css */
@import "tailwindcss";Configuring main.ts and the PostCSS Pipeline
Storybook needs to know how to process your CSS. For Vite-based projects, this is largely automatic because Vite already handles PostCSS. But if you're on Webpack (older setups, or the @storybook/react-webpack5 builder), you need to explicitly configure it.
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-themes', // add this
],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;For Webpack users, you'd also add a webpackFinal function and push a PostCSS rule. That said, I'd genuinely recommend migrating to the Vite builder at this point — the rebuild speed alone is worth it. Going from 8-second HMR to under 300ms for component updates changes how you work.
One more thing — if you're using Tailwind's content array in v3, you need to include your stories in it or the classes won't be generated:
// tailwind.config.js (v3)
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
'./.storybook/**/*.{js,ts,jsx,tsx}', // include storybook files
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
};Adding a Background Decorator for Dark Mode Contrast
The theme switcher toggles the dark class, but the story canvas background won't automatically update to a dark color — that's handled separately. You've got two options: use Storybook's built-in backgrounds addon, or use a custom decorator that reads the current theme.
The cleaner approach is a decorator that applies a background class to the story wrapper, so your components sit on the right surface color regardless of which theme is active:
// .storybook/preview.ts (updated decorators array)
import type { Decorator } from '@storybook/react';
const withBackground: Decorator = (Story, context) => {
const isDark = context.globals.theme === 'dark';
return (
<div className={`min-h-screen p-8 ${
isDark ? 'bg-gray-950 text-white' : 'bg-white text-gray-900'
}`}>
<Story />
</div>
);
};
// Then in preview.ts:
decorators: [
withThemeByClassName({ themes: { light: '', dark: 'dark' }, defaultTheme: 'light' }),
withBackground,
],That 8px padding (p-8 = 32px) stops your component from bleeding into the canvas edge. Looks way better in screenshots and makes your design reviews less janky. If you're building something like the glassmorphism components where the backdrop-blur only reads correctly on a colored background, this decorator becomes mandatory — a white wrapper kills the effect entirely.
Look, you could skip the background decorator and just tell everyone to mentally ignore the canvas color. But if you're sharing Storybook with designers, that confusion adds up fast.
Building a Custom Theme Switcher Toolbar Item
The default withThemeByClassName toolbar button works, but it's generic. If you're building a real design system and want to support more than two themes — say, light, dark, and a high-contrast mode — you need to configure globalTypes in your preview.
// .storybook/preview.ts
export const globalTypes = {
theme: {
description: 'Global theme',
toolbar: {
title: 'Theme',
icon: 'circlehollow',
items: [
{ value: 'light', title: 'Light', icon: 'sun' },
{ value: 'dark', title: 'Dark', icon: 'moon' },
{ value: 'high-contrast', title: 'High Contrast', icon: 'accessibility' },
],
dynamicTitle: true,
},
},
};Then your decorator reads context.globals.theme to apply the right class. The high-contrast theme would need a corresponding Tailwind config variant or CSS variables that override the defaults. If you're using the gradient generator outputs or custom design tokens, this is where you'd slot those in per-theme.
Worth noting: Storybook's toolbar icons come from the @storybook/icons package (available since Storybook 7.6). The sun, moon, and accessibility strings map to actual SVG icons in that package. You can browse the full list in their Storybook playground.
// Full custom theme decorator
const withTheme: Decorator = (Story, context) => {
const theme = context.globals.theme || 'light';
const classMap: Record<string, string> = {
light: '',
dark: 'dark',
'high-contrast': 'dark high-contrast',
};
const bgMap: Record<string, string> = {
light: 'bg-white',
dark: 'bg-gray-950',
'high-contrast': 'bg-black',
};
// Apply class to html element for Tailwind dark mode
document.documentElement.className = classMap[theme];
return (
<div className={`min-h-screen p-8 ${bgMap[theme]}`}>
<Story />
</div>
);
};Testing It With a Real Component
Write a quick component that uses both light and dark Tailwind utilities to confirm the whole pipeline is wired up. If you see the correct colors in both modes, you're done. If dark mode isn't toggling, the dark class isn't reaching your iframe's <html> element.
// src/components/Card.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
const Card = ({ title, description }: { title: string; description: string }) => (
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{description}</p>
</div>
);
const meta: Meta<typeof Card> = {
title: 'Components/Card',
component: Card,
};
export default meta;
type Story = StoryObj<typeof Card>;
export const Default: Story = {
args: {
title: 'Design System Card',
description: 'Switch the theme in the toolbar to test dark mode.',
},
};Run npm run storybook and flip between Light and Dark in the toolbar. The card should go from white to gray-900 background with matching text colors. If it doesn't, check that darkMode: 'class' is in your tailwind.config.js — that's the most common miss.
Once it's working, browse the Empire UI component library for reference on how production-grade components handle their dark/light variants. Seeing patterns like how the glassmorphism generator outputs theme-aware CSS can inform how you structure your own utility classes.
That's the whole setup. It's not complicated once you know which three files to touch — preview.ts, main.ts, and your Tailwind config. The iframe boundary is the only real gotcha, and withThemeByClassName solves it cleanly.
FAQ
The dark class needs to be on the <html> element inside the story iframe, not the parent page. Use @storybook/addon-themes with withThemeByClassName — it handles exactly this. Make sure you've also set darkMode: 'class' in your Tailwind v3 config.
No. One config file covers both your app and Storybook. Just add .storybook/**/*.{js,ts,jsx,tsx} to the content array in v3, or Tailwind v4 picks up all files automatically. Import the same globals.css from both your app entry and preview.ts.
Yes, but the setup differs slightly. Tailwind v4 drops tailwind.config.js and moves config into CSS via @theme. You still import your CSS file in preview.ts the same way. Use @tailwindcss/vite as the Vite plugin instead of PostCSS — it's faster and works fine with Storybook's Vite builder.
Define it in globalTypes with your own toolbar items, then write a custom decorator that applies the right class string to document.documentElement. Add corresponding Tailwind utilities or CSS variable overrides under a .high-contrast or .brand selector in your stylesheet.