Introduction

Working with frontend in Drupal has traditionally been tightly coupled with the CMS. This means that building, testing, and iterating on UI components often requires a full Drupal environment running behind it — making the feedback loop slow and painful for frontend developers.

Storybook offers a way out of this loop. It provides an isolated sandbox where you can develop, test, and document your components entirely outside of Drupal, without needing a running backend. With the introduction of Single Directory Components (SDC) in Drupal 10.1+, this approach has become even more natural. SDC bundles everything a component needs — Twig template, CSS, JavaScript, and a schema definition — into a single self-contained directory.

In this guide, we'll walk through how to set up Storybook in a Drupal project, write stories for your components, and integrate them with SDC so your theme system stays consistent, testable, and maintainable.

Why This Combination?

Prerequisites

Step 1: Initialize Storybook

Navigate to your theme directory and run the Storybook initializer:

cd web/themes/custom/mytheme
npx storybook@latest init --type html

This scaffolds a .storybook/ config directory and a stories/ folder. Since Drupal uses Twig (not React or Vue), we initialize with the html type and will add Twig rendering later.

After initialization, verify it works:

npm run storybook

Step 2: Add Twig Support

Storybook doesn't natively understand Twig. To bridge this gap, we use twig-loader and the TwigJS rendering engine.

npm install --save-dev twig-loader twig

Then, update your Storybook Webpack configuration. Create or modify .storybook/main.js:

module.exports = {
  stories: [
    '../components/**/*.stories.@(js|jsx|ts|tsx)',
  ],
  framework: '@storybook/html',
  webpackFinal: async (config) => {
    config.module.rules.push({
      test: /\.twig$/,
      use: 'twig-loader',
    });
    return config;
  },
};

Step 3: Create an SDC Component

Single Directory Components follow a strict directory structure. Each component lives in a self-contained folder under components/:

mytheme/
└── components/
    └── button/
        ├── button.twig
        ├── button.css
        └── button.component.yml

button.component.yml

The schema file that defines the component's API:

name: Button
status: stable
props:
  type: object
  properties:
    label:
      type: string
      title: Label
      description: The text inside the button
      default: "Click Me"
    variant:
      type: string
      title: Variant
      enum:
        - primary
        - secondary
        - outline
      default: primary

button.twig

<button class="btn btn--{{ variant|default('primary') }}">
  {{ label|default('Click Me') }}
</button>

button.css

.btn {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.2s;
}
.btn--primary {
  background-color: #0057ff;
  color: white;
}
.btn--secondary {
  background-color: #6c757d;
  color: white;
}
.btn--outline {
  background-color: transparent;
  border: 2px solid #0057ff;
  color: #0057ff;
}

Step 4: Write a Story

Create a story file at components/button/button.stories.js:

import buttonTemplate from './button.twig';
import './button.css';

export default {
  title: 'Components/Button',
  argTypes: {
    label: { control: 'text', defaultValue: 'Click Me' },
    variant: {
      control: { type: 'select' },
      options: ['primary', 'secondary', 'outline'],
      defaultValue: 'primary',
    },
  },
};

const Template = (args) => buttonTemplate(args);

export const Primary = Template.bind({});
Primary.args = { label: 'Primary Button', variant: 'primary' };

export const Secondary = Template.bind({});
Secondary.args = { label: 'Secondary', variant: 'secondary' };

export const Outline = Template.bind({});
Outline.args = { label: 'Outline', variant: 'outline' };

Step 5: Scale It — Complex Components

Once the basics work, you can apply the same pattern to more complex components. Here's a Card component example:

mytheme/
└── components/
    └── card/
        ├── card.twig
        ├── card.css
        ├── card.component.yml
        └── card.stories.js

card.twig

<article class="card {{ modifier_class|default('') }}">
  {% if image %}
    <div class="card__image">
      <img src="{{ image }}" alt="{{ image_alt|default('') }}">
    </div>
  {% endif %}
  <div class="card__content">
    <h3 class="card__title">{{ title }}</h3>
    {% if body %}
      <p class="card__body">{{ body }}</p>
    {% endif %}
    {% if cta_label %}
      <a href="{{ cta_url|default('#') }}" class="card__cta">{{ cta_label }}</a>
    {% endif %}
  </div>
</article>

Keeping Drupal and Storybook in Sync

The key to maintaining parity between Drupal and Storybook is treating your SDC components as the single source of truth. Here's how:

  1. Never hardcode styles in templates — Always reference your component CSS.
  2. Use the .component.yml schema as a contract — Both Drupal's render engine and your Storybook stories should reference the same property set.
  3. Automate visual regression — Use Chromatic or Percy to catch visual drift between what's in Storybook and what ships to production.
  4. Reference design tokens — If you're using a design system, centralize your CSS variables and import them in both Storybook's preview and your Drupal theme.

Conclusion

Setting up Storybook with SDC in Drupal is not just a nice-to-have — it's a fundamental shift in how you approach frontend architecture. By isolating your components, you gain:

The effort to set it up pays for itself quickly, especially in teams that maintain large component libraries or need to iterate frequently on UI without waiting for backend deployments.