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:
- Drupal 10 or higher
- DDEV (for local development)
- Drush (Drupal's command-line tool)
- Bun or npm (I'm using Bun here because it's faster, but npm works fine too)
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:
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:
- 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:
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.
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:
ddev drush generate sdcAnswer 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:
ddev drush crMaking 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:
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 crBuilding Out the Component Template
Before 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.
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:
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 formatAdding 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:
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-storybookYour 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:
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 formatCommon Daily Commands
Here 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_sdcTroubleshooting 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.ymlfor thelibraries: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 installto reinstall everything - Check that
src/main.jsandsrc/main.cssexist - Verify
vite.config.jsis 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_modulesand runbun installagain - Make sure nothing else is using port 6006
- Verify
.storybook/main.jsand.storybook/preview.jsexist
Changes Aren't Showing Up
The classic problem. Try these in order:
- Clear Drupal's cache:
ddev drush cr - Hard refresh your browser:
Cmd+Shift+Ron Mac orCtrl+Shift+Ron Windows/Linux - Check your browser console for JavaScript errors
- 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:
# 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-storybookBest Practices
Theme Development
- Use a base theme like
stable9for a solid foundation - Clear cache frequently with
drush cr(seriously, do this often) - Keep SDC components focused and reusable
- Follow naming conventions: lowercase with underscores
- Test before deploying to production
Component Development
- Delete generated CSS files when using Tailwind
- Store component JS alongside Twig templates
- Document props in
component.ymlmetadata - Use semantic versioning for dependencies
- Keep components organized and focused
Build Process
- Always use
bun run buildfor production deployments - Update library hashes after each build
- Leverage HMR with
bun run devduring development - Format code before committing with
bun run format - Build for production before pushing changes
Storybook Best Practices
- One story file per component
- Use descriptive story names
- Test edge cases (empty states, errors, loading)
- Enable autodocs with
tags: ['autodocs'] - Always check the a11y panel before committing
- Test components on different background colors
- 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! 🎨