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.
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.
First, let's create a proper home for your custom theme. Drupal expects custom themes to live in a specific place:
mkdir -p web/themes/customNow 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:
ddev drush generate themeHere's what you'll be prompted for:
Blog SDC (this is the human-readable name)blog_sdc (lowercase, underscores only)stable9 (this is important—don't skip it or you'll get dependency errors later)A custom theme for testing SDC componentsCustomNo (we'll use Tailwind for responsive design)No (keep it simple for now)Once your theme is generated, let's install it and make it the default theme for your site:
ddev drush theme:install blog_sdc -y
ddev drush config-set system.theme default blog_sdc -y
ddev drush config-get system.theme defaultThat last command just confirms your theme is now active. You should see blog_sdc in the output.
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:
ddev drush generate sdcAnswer the prompts:
blog_sdcCard ComponentcardA reusable card componentyn (we'll add this later)nnAfter generation, clear Drupal's cache. You'll be doing this a lot, so get used to this command:
ddev drush crNow 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:
cp web/sites/default/default.services.yml web/sites/default/services.ymlOpen web/sites/default/services.yml and find the twig.config section. You're only changing three values here—don't replace the whole section:
parameters:
twig.config:
debug: true
auto_reload: true
cache: falseThese 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:
ddev composer require drupal/devel drupal/twig_tweak 'drupal/kint-kint:^2.2'
ddev drush en devel devel_generate twig_tweak kint -yThese 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:
ddev drush devel-generate:content 1 --bundles=articleFind your article's node ID (you'll need this):
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:
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.twigOpen templates/node/node--article.html.twig and replace everything with this single include statement:
{% 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:
'$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 URLClear that cache again:
ddev drush crBefore we add Tailwind, let's make sure our component actually displays something. Edit components/card/card.twig:
<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):
[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:
ddev drush crVisit 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.
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:
cd web/themes/custom/blog_sdc
bun init -yInstall the dependencies we need:
bun add -D vite @tailwindcss/vite tailwindcss
bun add -D prettier prettier-plugin-tailwindcss @ttskch/prettier-plugin-tailwindcss-anywherePrettier 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:
{
"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:
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:
mkdir -p srcCreate src/main.css with Tailwind directives:
@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:
/**
* @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:
{
"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:
rm components/card/card.cssNow let's rebuild the card component with Tailwind utilities. Edit components/card/card.twig:
<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:
/**
* @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:
bun run buildPay attention to the output. You'll see something like:
dist/main-BuEGDbdA.css
dist/main-BicIW-eB.jsThose 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:
# 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:
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-mainClear cache one more time:
ddev drush crWant your code to look pretty? Run the formatter:
bun run formatHere'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:
npx storybook@latest init --builder vite --type html --no-dev --yesClean up the example stories they include:
rm -rf storiesInstall the accessibility addon (this is super useful):
bun add -D @storybook/addon-a11y@10.0.7Remove the TypeScript configs (we're keeping it simple):
rm .storybook/main.ts .storybook/preview.tsCreate .storybook/main.js:
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:
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):
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:
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:
{
"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:
bun run storybookOpen 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.
Want to build a static version for deployment or sharing with your team?
bun run build-storybookEcho Flow provides Canadian businesses with enterprise-grade Drupal engineering.
Now that everything's set up, here's how you'll actually work day-to-day.
Let's walk through adding a new component from scratch. Say you want to add a Button component:
Generate the component:
ddev drush generate sdc
# Theme: blog_sdc, Component: Button, machine: buttonDelete the CSS file (we're using Tailwind, remember?):
rm components/button/button.cssStyle it with Tailwind in components/button/button.twig:
<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:
export default function initButton() {
console.log('Button component initialized');
// Your button logic here
}components/button/button.stories.js with different variantsTest it in Storybook:
bun run storybookBuild your assets:
bun run buildblog_sdc.libraries.yml with the new values from the build outputClear Drupal's cache:
ddev drush crFormat your code:
bun run formatHere are the commands you'll use constantly:
# 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_sdcLet's be honest—things will go wrong. Here's how to fix the most common issues.
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.
This is almost always a caching issue. Run ddev drush cr and try again. If that doesn't work:
web/themes/custom/blog_sdc/components/card/ddev drush pm:list --type=themeddev drush config-get system.theme defaultA few things to check:
bun run build?blog_sdc.libraries.yml?blog_sdc.info.yml for the libraries: sectionddev drush crUsually this means a dependency issue:
bun install to reinstall everythingsrc/main.js and src/main.css existvite.config.js is in your theme's root directorynode_modules and run bun install again.storybook/main.js and .storybook/preview.js existThe classic problem. Try these in order:
ddev drush crCmd+Shift+R on Mac or Ctrl+Shift+R on Windows/LinuxIf 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.
For when you just need to copy-paste something quickly:
# 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-storybookstable9 for a solid foundationdrush cr (seriously, do this often)component.yml metadatabun run build for production deploymentsbun run dev during developmentbun run formattags: ['autodocs']This isn't just a theme—it's a complete modern development environment:
Based on this workflow, here's what you can expect:
This is significantly faster than traditional Drupal theming workflows.
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! 🎨