Skip to main content
Login

Modern Drupal Theming: Building with SDC, Tailwind, Vite & Storybook

Denys Kravchenko profile picture
by Denys Kravchenko
Modern Drupal Theme with SDC & Storybook

If you've been watching the front-end world evolve with React, Vue, and all those shiny JavaScript frameworks, you might be wondering if Drupal can keep up. The good news? It absolutely can. With Single Directory Components (SDC), Tailwind CSS v4, Vite, and Storybook, you can build component-driven Drupal themes that feel surprisingly modern.

This guide walks you through setting up a complete modern theming workflow. I've tested every command here, fixed the gotchas, and documented the stuff that usually trips people up. Let's dive in.

Before You Start

You'll need a few things already installed on your machine:

If you're missing any of these, get them set up first. Trust me, it'll save you headaches later.

Setting Up Your Theme Foundation

First, let's create a proper home for your custom theme. Drupal expects custom themes to live in a specific place:

bash
mkdir -p web/themes/custom

Now comes the fun part. Drush has a theme generator that'll ask you a bunch of questions. Don't worry—I'll tell you what to answer:

bash
ddev drush generate theme

Here's what you'll be prompted for:

  • Theme name: Blog SDC (this is the human-readable name)
  • Theme machine name: blog_sdc (lowercase, underscores only)
  • Base theme: stable9 (this is important—don't skip it or you'll get dependency errors later)
  • Description: A custom theme for testing SDC components
  • Package: Custom
  • Create breakpoints? No (we'll use Tailwind for responsive design)
  • Create theme settings form? No (keep it simple for now)

Once your theme is generated, let's install it and make it the default theme for your site:

bash
ddev drush theme:install blog_sdc -y
ddev drush config-set system.theme default blog_sdc -y
ddev drush config-get system.theme default

That last command just confirms your theme is now active. You should see blog_sdc in the output.

Creating Your First Component

Single Directory Components (SDC) are game-changers. Instead of scattering your component files across multiple directories, everything lives together—template, CSS, JavaScript, and configuration all in one folder. It's the component architecture you've wanted in Drupal.

Let's generate a Card component:

bash
ddev drush generate sdc

Answer the prompts:

  • Theme machine name: blog_sdc
  • Component name: Card Component
  • Component machine name: card
  • Component description: A reusable card component
  • Library dependencies: (just press Enter to skip)
  • Need CSS? y
  • Need JS? n (we'll add this later)
  • Need component props? n
  • Need slots? n

After generation, clear Drupal's cache. You'll be doing this a lot, so get used to this command:

bash
ddev drush cr

Making Your Component Actually Work

Now here's where things get interesting. We need to connect our component to actual Drupal content. First, let's set up Twig debugging so you can actually see what's happening under the hood.

Copy the default services file:

bash
cp web/sites/default/default.services.yml web/sites/default/services.yml

Open web/sites/default/services.yml and find the twig.config section. You're only changing three values here—don't replace the whole section:

yaml
parameters:
  twig.config:
    debug: true
    auto_reload: true
    cache: false

These settings will show you helpful comments in your HTML source and reload templates automatically. Life-savers during development.

Now let's install some developer tools that'll make your life easier:

bash
ddev composer require drupal/devel drupal/twig_tweak 'drupal/kint-kint:^2.2'
ddev drush en devel devel_generate twig_tweak kint -y

These modules are essential for development: Devel provides debugging tools, Twig Tweak adds helpful Twig functions, and Kint gives you beautiful variable dumps.

Quick note: Yes, it's drupal/kint-kint with the name repeated. It's weird, but that's the actual package name due to a conflict with another package.

Generate a test article to work with:

bash
ddev drush devel-generate:content 1 --bundles=article

Find your article's node ID (you'll need this):

bash
ddev drush sqlq "SELECT nid, title FROM node_field_data WHERE type='article' LIMIT 1"

Now let's override the article template to use our card component. Create the template directory and copy the base template:

bash
mkdir -p web/themes/custom/blog_sdc/templates/node
cp web/core/themes/stable9/templates/content/node.html.twig web/themes/custom/blog_sdc/templates/node/node--article.html.twig

Open templates/node/node--article.html.twig and replace everything with this single include statement:

plaintext
{% include 'blog_sdc:card' with {
  title: label,
  content: content.body,
  image: content.field_image,
  tags: content.field_tags,
  url: url
} %}

This is where the magic happens. You're telling Drupal "use my card component and pass it these values." Notice how clean this is compared to traditional template overrides.

But we need to tell our component what data to expect. Edit components/card/card.component.yml:

yaml
22 lines
'$schema': '<https://git.drupalcode.org/project/drupal/-/raw/10.1.x/core/modules/sdc/src/metadata.schema.json>'
name: Card Component
status: stable
description: A reusable card component
props:
  type: object
  properties:
    title:
      type: string
      title: Card Title
    content:
      type: string
      title: Card Content
    image:
      type: string
      title: Card Image
    tags:
      type: string
      title: Card Tags
    url:
      type: string
      title: Card URL

Clear that cache again:

bash
ddev drush cr

Building Out the Component Template

Before we add Tailwind, let's make sure our component actually displays something. Edit components/card/card.twig:

plaintext
<article {{ attributes.addClass('card') }}>
  {% if title %}
    <h2 class="card__title">
      {% if url %}
        <a href="{{ url }}">{{ title }}</a>
      {% else %}
        {{ title }}
      {% endif %}
    </h2>
  {% endif %}

  {% if content %}
    <div class="card__content">
      {{ content }}
    </div>
  {% endif %}
</article>

And add some basic styling to components/card/card.css (we'll replace this with Tailwind soon):

css
33 lines
[data-component-id="blog_sdc:card"] {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 1.5rem;
  margin-bottom: 1.5rem;
  background: #fff;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: box-shadow 0.3s ease;
}

[data-component-id="blog_sdc:card"]:hover {
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}

[data-component-id="blog_sdc:card"] .card__title {
  margin: 0 0 1rem 0;
  font-size: 1.5rem;
  color: #333;
}

[data-component-id="blog_sdc:card"] .card__title a {
  color: #0073e6;
  text-decoration: none;
}

[data-component-id="blog_sdc:card"] .card__title a:hover {
  text-decoration: underline;
}

[data-component-id="blog_sdc:card"] .card__content {
  color: #666;
  line-height: 1.6;
}

Clear cache and test it:

bash
ddev drush cr

Visit your article (probably at /node/1) and you should see your card component in action! If you ran that SQL query earlier, you'll know exactly which URL to visit.

Bringing in Tailwind CSS v4 and Vite

This is where things get really interesting. We're going to set up a modern build system with Vite and Tailwind CSS v4. Navigate into your theme directory:

bash
cd web/themes/custom/blog_sdc
bun init -y

Install the dependencies we need:

bash
bun add -D vite @tailwindcss/vite tailwindcss
bun add -D prettier prettier-plugin-tailwindcss @ttskch/prettier-plugin-tailwindcss-anywhere

Prettier will format your code automatically, including sorting Tailwind classes. That second Prettier plugin is special—it lets Prettier understand Twig templates, which vanilla Prettier can't handle.

Create a .prettierrc file:

json
{
  "plugins": ["prettier-plugin-tailwindcss", "@ttskch/prettier-plugin-tailwindcss-anywhere"],
  "overrides": [
    {
      "files": ["*.twig", "*.html.twig"],
      "options": {
        "parser": "anywhere",
        "regex": "(?:class(?:Name)?|addClass)\\\\\\\\s*(?:[=:]|\\\\\\\\()\\\\\\\\s*[\\\\"']([^\\\\"'{}~]*)"
      }
    }
  ],
  "printWidth": 100,
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5"
}

Now configure Vite. Create vite.config.js in your theme root:

jsx
29 lines
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import { resolve } from 'path';

export default defineConfig({
  plugins: [tailwindcss()],
  build: {
    outDir: 'dist',
    manifest: true,
    emptyOutDir: true,
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'src/main.js'),
      },
      output: {
        entryFileNames: '[name]-[hash].js',
        chunkFileNames: '[name]-[hash].js',
        assetFileNames: '[name]-[hash].[ext]',
      },
    },
  },
  server: {
    host: '0.0.0.0',
    port: 5173,
    strictPort: true,
    cors: true,
  },
  base: process.env.NODE_ENV === 'production' ? '/themes/custom/blog_sdc/dist/' : '/',
});

This configuration handles building your assets with cache-busting hashes and sets up a dev server with hot module replacement.

Create a source directory for your main JavaScript and CSS files:

bash
mkdir -p src

Create src/main.css with Tailwind directives:

css
@import 'tailwindcss';

@layer base {
  body {
    @apply text-gray-900;
  }
}

@layer components {
  /* Your custom component styles */
}

@layer utilities {
  /* Your custom utilities */
}

Here's where things get clever. Create src/main.js with automatic component discovery:

jsx
44 lines
/**
 * @file
 * Main JavaScript entry point for blog_sdc theme.
 */

// Import main CSS (includes Tailwind)
import './main.css';

// Auto-discover component JavaScript files
const componentModules = import.meta.glob('../components/**/*.js', { eager: false });

// Initialize components
async function initializeComponents() {
  const componentPaths = Object.keys(componentModules);

  console.log(`[blog_sdc] Found ${componentPaths.length} component JS files`);

  for (const path of componentPaths) {
    const componentName = path.match(/components\\\\/([^/]+)\\\\//)?.[1];
    const componentSelector = `[data-component-id="blog_sdc:${componentName}"]`;

    if (document.querySelector(componentSelector)) {
      console.log(`[blog_sdc] Loading component: ${componentName}`);

      try {
        const module = await componentModules[path]();
        if (module.default && typeof module.default === 'function') {
          module.default();
        }
      } catch (error) {
        console.error(`[blog_sdc] Error loading component ${componentName}:`, error);
      }
    }
  }
}

// Initialize when DOM is ready
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', initializeComponents);
} else {
  initializeComponents();
}

export { initializeComponents };

This script automatically finds and loads JavaScript for any component that's on the page. No more manually importing every component file!

Update your package.json with helpful scripts:

json
{
  "name": "blog_sdc",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "format": "prettier --write \\\\"src/**/*.{js,css}\\\\" \\\\"components/**/*.{js,twig}\\\\" \\\\"templates/**/*.twig\\\\""
  }
}

Since we're using Tailwind now, we don't need that CSS file anymore:

bash
rm components/card/card.css

Now let's rebuild the card component with Tailwind utilities. Edit components/card/card.twig:

plaintext
37 lines
<article {{ attributes.addClass('bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden mb-6') }}>
  {% if image %}
    <div class="aspect-video overflow-hidden">
      {{ image }}
    </div>
  {% endif %}

  {% if title %}
    <div class="p-6">
      <h2 class="text-xl font-semibold text-gray-900 mb-3">
        {% if url %}
          <a href="{{ url }}" class="text-blue-600 hover:text-blue-800 transition-colors">
            {{ title }}
          </a>
        {% else %}
          {{ title }}
        {% endif %}
      </h2>
    </div>
  {% endif %}

  {% if content %}
    <div class="px-6 pb-4">
      <div class="text-gray-600 leading-relaxed">
        {{ content }}
      </div>
    </div>
  {% endif %}

  {% if tags %}
    <div class="px-6 pb-6 border-t border-gray-100 pt-4">
      <div class="flex flex-wrap gap-2">
        {{ tags }}
      </div>
    </div>
  {% endif %}
</article>

Look at that—no CSS file needed. All the styling is right there in the template using Tailwind's utility classes.

Let's add some interactivity. Create components/card/card.js:

jsx
25 lines
/**
 * @file
 * Card component JavaScript.
 */

export default function initCard() {
  const cards = document.querySelectorAll('[data-component-id="blog_sdc:card"]');

  console.log(`[Card] Initializing ${cards.length} card(s)`);

  cards.forEach((card) => {
    card.addEventListener('mouseenter', () => {
      card.classList.add('card--hover');
    });

    card.addEventListener('mouseleave', () => {
      card.classList.remove('card--hover');
    });

    const title = card.querySelector('.card__title');
    if (title) {
      console.log(`[Card] Initialized: ${title.textContent.trim()}`);
    }
  });
}

Now build everything:

bash
bun run build

Pay attention to the output. You'll see something like:

plaintext
dist/main-BuEGDbdA.css
dist/main-BicIW-eB.js

Those hash values (BuEGDbdA and BicIW-eB) are important. Copy them—you'll need them in a second.

Edit blog_sdc.libraries.yml and add your built assets with the actual hash values:

yaml
# Vite-built assets with Tailwind CSS
vite-main:
  css:
    theme:
      dist/main-BuEGDbdA.css: {}  # Replace with your actual hash
  js:
    dist/main-BicIW-eB.js: {}     # Replace with your actual hash
  dependencies:
    - core/drupal

# Legacy theme library (if it exists)
global:
  css:
    theme:
      css/style.css: {}

Important: Those hash values change every time you build. If you modify your source files and rebuild, you'll need to update these hashes in your libraries file. It's annoying, but it's how cache-busting works.

Now tell your theme to load this library. Edit blog_sdc.info.yml:

yaml
name: Blog SDC
type: theme
description: 'A custom theme for testing SDC components'
package: Custom
core_version_requirement: ^10 || ^11
base theme: stable9
libraries:
  - blog_sdc/vite-main

Clear cache one more time:

bash
ddev drush cr

Want your code to look pretty? Run the formatter:

bash
bun run format

Adding Storybook for Component Development

Here's where the workflow really shines. Storybook lets you develop components in isolation, without needing to navigate through Drupal's admin interface or create test content. Initialize it:

bash
npx storybook@latest init --builder vite --type html --no-dev --yes

Clean up the example stories they include:

bash
rm -rf stories

Install the accessibility addon (this is super useful):

bash
bun add -D @storybook/addon-a11y@10.0.7

Remove the TypeScript configs (we're keeping it simple):

bash
rm .storybook/main.ts .storybook/preview.ts

Create .storybook/main.js:

jsx
23 lines
import { join } from 'node:path';
import { cwd } from 'node:process';

const config = {
  stories: ['../components/**/*.stories.js'],
  addons: [
    '@storybook/addon-a11y',
    '@storybook/addon-docs',
  ],
  framework: {
    name: '@storybook/html-vite',
    options: {
      builder: {
        viteConfigPath: './.storybook/vite.config.js',
      },
    },
  },
  core: {
    disableTelemetry: true,
  },
};

export default config;

Create .storybook/preview.js to configure how stories display:

jsx
34 lines
import '../src/main.css';

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
  backgrounds: {
    default: 'light',
    values: [
      { name: 'light', value: '#ffffff' },
      { name: 'dark', value: '#1a1a1a' },
      { name: 'gray', value: '#f3f4f6' },
    ],
  },
  layout: 'padded',
  a11y: {
    config: {
      rules: [{ id: 'color-contrast', enabled: true }],
    },
  },
};

export const decorators = [
  (Story) => {
    const wrapper = document.createElement('div');
    wrapper.style.padding = '1rem';
    wrapper.appendChild(Story());
    return wrapper;
  },
];

Create .storybook/vite.config.js (separate from your main Vite config):

jsx
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [tailwindcss()],
  optimizeDeps: {
    include: [],
  },
});

Now create your first story. Create components/card/card.stories.js:

jsx
92 lines
export default {
  title: 'Components/Card',
  tags: ['autodocs'],
  parameters: { layout: 'padded' },
  argTypes: {
    title: { control: 'text', description: 'The title displayed in the card header' },
    content: { control: 'text', description: 'The main content of the card' },
    image: { control: 'text', description: 'Optional image HTML for the card' },
    tags: { control: 'text', description: 'Optional tags HTML for the card' },
    url: { control: 'text', description: 'Optional URL for the title link' },
  },
};

const renderCard = (args) => {
  const article = document.createElement('article');
  article.className = 'mb-6 overflow-hidden rounded-lg bg-white shadow-md transition-shadow duration-300 hover:shadow-lg';
  article.setAttribute('data-component-id', 'blog_sdc:card');

  if (args.image) {
    const imageDiv = document.createElement('div');
    imageDiv.className = 'aspect-video overflow-hidden';
    imageDiv.innerHTML = args.image;
    article.appendChild(imageDiv);
  }

  if (args.title) {
    const titleDiv = document.createElement('div');
    titleDiv.className = 'p-6';
    const h2 = document.createElement('h2');
    h2.className = 'mb-3 text-xl font-semibold text-gray-900';
    if (args.url) {
      const link = document.createElement('a');
      link.href = args.url;
      link.className = 'text-blue-600 transition-colors hover:text-blue-800';
      link.textContent = args.title;
      h2.appendChild(link);
    } else {
      h2.textContent = args.title;
    }
    titleDiv.appendChild(h2);
    article.appendChild(titleDiv);
  }

  if (args.content) {
    const contentDiv = document.createElement('div');
    contentDiv.className = 'px-6 pb-4';
    const innerDiv = document.createElement('div');
    innerDiv.className = 'leading-relaxed text-gray-600';
    innerDiv.innerHTML = args.content;
    contentDiv.appendChild(innerDiv);
    article.appendChild(contentDiv);
  }

  if (args.tags) {
    const tagsDiv = document.createElement('div');
    tagsDiv.className = 'border-t border-gray-100 px-6 pt-4 pb-6';
    const innerDiv = document.createElement('div');
    innerDiv.className = 'flex flex-wrap gap-2';
    innerDiv.innerHTML = args.tags;
    tagsDiv.appendChild(innerDiv);
    article.appendChild(tagsDiv);
  }

  return article;
};

export const Basic = {
  args: { title: 'Basic Card', content: 'This is a basic card with minimal styling.' },
  render: renderCard,
};

export const WithImage = {
  args: {
    title: 'Card with Image',
    content: 'This card includes a featured image at the top.',
    image: '<img src="<https://placehold.co/600x400>" alt="Placeholder" class="w-full h-auto" />',
    url: '/example',
  },
  render: renderCard,
};

export const Complete = {
  args: {
    title: 'Complete Card',
    content: 'This card showcases all available features.',
    image: '<img src="<https://placehold.co/600x400>" alt="Complete" class="w-full h-auto" />',
    tags: '<span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm">Featured</span>',
    url: '/complete',
  },
  parameters: { backgrounds: { default: 'gray' } },
  render: renderCard,
};

Update your package.json one more time to add Storybook scripts:

json
{
  "name": "blog_sdc",
  "type": "module",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "format": "prettier --write \\\\"src/**/*.{js,css}\\\\" \\\\"components/**/*.{js,twig}\\\\" \\\\"templates/**/*.twig\\\\"",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  }
}

Fire up Storybook:

bash
bun run storybook

Open http://localhost:6006 in your browser. You should see your card component with three different variants you can interact with and modify in real-time.

Storybook 10 running in browser

Want to build a static version for deployment or sharing with your team?

bash
bun run build-storybook

Your Daily Development Workflow

Now that everything's set up, here's how you'll actually work day-to-day.

Creating New Components

Let's walk through adding a new component from scratch. Say you want to add a Button component:

  1. Generate the component:

bash
ddev drush generate sdc
# Theme: blog_sdc, Component: Button, machine: button
  • Delete the CSS file (we're using Tailwind, remember?):

  • bash
    rm components/button/button.css
  • Style it with Tailwind in components/button/button.twig:

  • plaintext
    <button {{ attributes.addClass('px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors') }}>
      {{ label }}
    </button>
  • Add JavaScript if needed in components/button/button.js:

  • jsx
    export default function initButton() {
      console.log('Button component initialized');
      // Your button logic here
    }
  • Create a story in components/button/button.stories.js with different variants
  • Test it in Storybook:

  • bash
    bun run storybook
  • Build your assets:

  • bash
    bun run build
  • Update the library hashes in blog_sdc.libraries.yml with the new values from the build output
  • Clear Drupal's cache:

  • bash
    ddev drush cr
  • Format your code:

  • bash
    bun run format

    Common Daily Commands

    Here are the commands you'll use constantly:

    bash
    # Start the Vite dev server (hot reload, much faster than rebuilding)
    bun run dev
    
    # Start Storybook (usually in a separate terminal)
    bun run storybook
    
    # Generate test content when you need more articles to work with
    ddev drush devel-generate:content 5 --bundles=article
    
    # Find node IDs to visit specific content
    ddev drush sqlq "SELECT nid, title FROM node_field_data WHERE type='article' LIMIT 5"
    
    # Clear cache (seriously, do this all the time)
    ddev drush cr
    
    # Check what themes are installed
    ddev drush pm:list --type=theme
    
    # Uninstall a theme if you need to start over
    ddev drush theme:uninstall blog_sdc

    Troubleshooting Common Issues

    Let's be honest—things will go wrong. Here's how to fix the most common issues.

    "Unmet Dependencies" Error When Installing the Theme

    This usually means your blog_sdc.info.yml has a problem. Check that base theme: is set to a valid theme name like stable9. The Drush generator sometimes gets confused and mixes up the order of inputs during generation.

    Component Not Showing Up

    This is almost always a caching issue. Run ddev drush cr and try again. If that doesn't work:

    • Verify the component files actually exist in web/themes/custom/blog_sdc/components/card/
    • Check that your theme is installed: ddev drush pm:list --type=theme
    • Confirm your theme is set as default: ddev drush config-get system.theme default

    Tailwind Styles Aren't Loading

    A few things to check:

    • Did you run bun run build?
    • Did you update the hash values in blog_sdc.libraries.yml?
    • Is the library actually being loaded? Check blog_sdc.info.yml for the libraries: section
    • Open your browser's dev tools, go to the Network tab, and look for your CSS file
    • Clear Drupal's cache: ddev drush cr

    Vite Build Fails

    Usually this means a dependency issue:

    • Run bun install to reinstall everything
    • Check that src/main.js and src/main.css exist
    • Verify vite.config.js is in your theme's root directory
    • Look at the error message—Vite usually tells you exactly what's wrong

    Storybook Won't Start

    • Check your Node.js version (18 or higher is recommended)
    • Delete node_modules and run bun install again
    • Make sure nothing else is using port 6006
    • Verify .storybook/main.js and .storybook/preview.js exist

    Changes Aren't Showing Up

    The classic problem. Try these in order:

    1. Clear Drupal's cache: ddev drush cr
    2. Hard refresh your browser: Cmd+Shift+R on Mac or Ctrl+Shift+R on Windows/Linux
    3. Check your browser console for JavaScript errors
    4. Make sure you're editing files in the right theme directory

    Kint Module Error

    If you get an error installing Kint, use the exact package name: drupal/kint-kint (yes, "kint" appears twice). This is because of a naming conflict with another package. It's confusing, but that's just how it is.

    Quick Command Reference

    For when you just need to copy-paste something quickly:

    bash
    33 lines
    # Clear cache (use this constantly)
    ddev drush cr
    
    # Generate a new SDC component
    ddev drush generate sdc
    
    # Build assets for production
    bun run build
    
    # Start dev server with hot reload
    bun run dev
    
    # Start Storybook
    bun run storybook
    
    # Format your code
    bun run format
    
    # Generate test content
    ddev drush devel-generate:content 5 --bundles=article
    
    # Find node IDs
    ddev drush sqlq "SELECT nid, title FROM node_field_data WHERE type='article' LIMIT 5"
    
    # Check installed themes
    ddev drush pm:list --type=theme
    
    # Get current default theme
    ddev drush config-get system.them
    e default
    
    # Build static Storybook for deployment
    bun run build-storybook

    Best Practices

    Theme Development

    1. Use a base theme like stable9 for a solid foundation
    2. Clear cache frequently with drush cr (seriously, do this often)
    3. Keep SDC components focused and reusable
    4. Follow naming conventions: lowercase with underscores
    5. Test before deploying to production

    Component Development

    1. Delete generated CSS files when using Tailwind
    2. Store component JS alongside Twig templates
    3. Document props in component.yml metadata
    4. Use semantic versioning for dependencies
    5. Keep components organized and focused

    Build Process

    1. Always use bun run build for production deployments
    2. Update library hashes after each build
    3. Leverage HMR with bun run dev during development
    4. Format code before committing with bun run format
    5. Build for production before pushing changes

    Storybook Best Practices

    1. One story file per component
    2. Use descriptive story names
    3. Test edge cases (empty states, errors, loading)
    4. Enable autodocs with tags: ['autodocs']
    5. Always check the a11y panel before committing
    6. Test components on different background colors
    7. Keep stories simple and focused

    What You've Built

    This isn't just a theme—it's a complete modern development environment:

    • Custom Drupal theme with SDC support
    • Tailwind CSS v4 with utility-first styling
    • Vite build system with hot reload
    • Component JavaScript auto-discovery
    • Storybook for isolated component development
    • Prettier for code formatting
    • Accessibility testing built-in
    • Complete development workflow

    Development Timeline

    Based on this workflow, here's what you can expect:

    Initial Setup

    • Theme generation: ~5 minutes
    • Tailwind + Vite setup: ~10 minutes
    • Storybook integration: ~10 minutes
    • Total: ~25-30 minutes

    Per Component

    • Generate component: ~30 seconds
    • Design with Tailwind: ~5-10 minutes
    • Add JavaScript: ~5-10 minutes (if needed)
    • Create stories: ~5-10 minutes
    • Build and test: ~2-3 minutes
    • Total: ~15-30 minutes per component

    This is significantly faster than traditional Drupal theming workflows.

    Key Takeaways

    • Modern Drupal theming combines SDC for reusable components, Tailwind for utility-first styling, Vite for fast builds, and Storybook for isolated development
    • Complete setup takes 25-30 minutes, with new components ready in 15-30 minutes
    • This workflow rivals modern JavaScript frameworks while staying within Drupal's ecosystem
    • Component auto-discovery scales as you add more components
    • Prettier integration keeps your code clean and consistent
    • No separate CSS files needed—Tailwind utilities replace custom CSS
    • Manual integration gives you full control over asset loading
    • Accessibility testing is built-in from the start
    • The development experience is fast, modern, and enjoyable

    Wrapping Up

    You now have a production-ready modern theming workflow that brings Drupal into the same league as contemporary JavaScript frameworks. The combination of SDC, Tailwind, Vite, and Storybook gives you component isolation, rapid iteration, and a developer experience that actually feels good.

    The real power here isn't just the tools—it's how they work together. You can prototype in Storybook, style with utility classes, and deploy to Drupal without context switching. Your components are portable, your builds are fast, and your workflow scales as your project grows.

    Start small. Build a few components, get comfortable with the workflow, then expand your component library. Before long, you'll wonder how you ever built Drupal themes any other way.


    Happy theming! 🎨