Pattern Override Cascade
extraPatternRoots supports any number of override roots — each root's templates overwrite earlier ones at the same relative namespace path. The most common use case is a WordPress parent/child theme: the parent theme provides base patterns, the child theme overrides specific ones.
For themes built on a shared pattern library (a separate repo or package containing base patterns), add that library as patternsRoot and your theme's patterns as extraPatternRoots. See the advanced example at the end of this page.
How it works
Template resolution is determined at build time by the patternsRoot + extraPatternRoots merge order. The addon walks each root and builds a registry of namespace key → template content. Later roots overwrite keys from earlier roots — last entry wins.
For example, if both the parent theme and the child theme have @molecules/card/_card.tpl.twig, only the child theme's version is registered in the browser.
Child theme setup
A complete child theme .storybook/main.ts with parent/child override:
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const themesDir = path.resolve(ROOT, '..');
// Auto-detect the parent theme (the sibling directory that contains a patterns directory)
let PARENT_THEME: string | null = null;
for (const entry of fs.readdirSync(themesDir)) {
if (entry === path.basename(ROOT)) continue;
const candidate = path.join(themesDir, entry);
try {
if (
fs.statSync(candidate).isDirectory() &&
fs.existsSync(path.join(candidate, 'src/patterns'))
) {
PARENT_THEME = candidate;
break;
}
} catch { /* skip */ }
}
if (!PARENT_THEME) throw new Error('[Storybook] Could not find parent theme');
export default {
framework: { name: '@storybook/html-vite', options: {} },
addons: [
{
name: '@pdrittenhouse/storybook-addon-twig-wordpress',
options: {
patternsRoot: path.join(PARENT_THEME, 'src/patterns'),
namespaces: {
atoms: 'atoms',
molecules: 'molecules',
organisms: 'organisms',
},
extraPatternRoots: [
path.join(ROOT, 'src/patterns'), // child overrides parent
],
macrosRoot: path.join(PARENT_THEME, 'src/macros'),
extraMacrosRoots: [path.join(ROOT, 'src/macros')],
scssVariablesPath: path.join(ROOT, 'dist/scss-variables.json'),
sassAdditionalData: `@use "${path.join(ROOT, 'src/patterns/_variables.scss').replace(/\\/g, '/')}" as *;`,
},
},
],
// See story deduplication below
stories: [...baseStories, '../src/patterns/**/*.stories.ts'],
// Serve parent compiled assets first so the child's compiled output can override them.
// The child's webpack publicPath determines whether its output lands in dist/ or dist/wp/.
staticDirs: [
{ from: `${PARENT_THEME}/dist/wp`, to: '/' }, // parent compiled assets
{ from: '../dist', to: '/' }, // child compiled assets (override parent)
],
};Story deduplication with findStoryFiles()
The stories array controls what appears in the Storybook sidebar. Without deduplication, parent stories appear alongside child story overrides — you get duplicates.
Use explicit file lists with findStoryFiles() to exclude overridden stories:
function findStoryFiles(dir: string): string[] {
const results: string[] = [];
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findStoryFiles(full));
} else if (entry.name.endsWith('.stories.ts')) {
results.push(full);
}
}
} catch { /* skip inaccessible dirs */ }
return results;
}
const childPatternsDir = path.join(ROOT, 'src/patterns');
const basePatternsDir = path.join(PARENT_THEME, 'src/patterns');
const childRelPaths = new Set(findStoryFiles(childPatternsDir).map(f => path.relative(childPatternsDir, f)));
// Parent/child cascade: child overrides parent at the same relative path
const baseStories = findStoryFiles(basePatternsDir).filter(f => {
const rel = path.relative(basePatternsDir, f);
return !childRelPaths.has(rel);
});
// stories array: parent (filtered) + child (glob, all)
stories: [
...baseStories,
'../src/patterns/**/*.stories.ts',
],This produces exactly one story per pattern — the highest-priority version.
Overriding a pattern
To override a parent pattern in a child theme, create a file at the same relative path within the child's src/patterns/ directory:
# Parent theme:
parent-theme/src/patterns/atoms/button/_button.tpl.twig
# Child override:
child-theme/src/patterns/atoms/button/_button.tpl.twigThe child template registers under the same @atoms/button/_button.tpl.twig key, replacing the parent version in the registry. A matching button.stories.ts in the child replaces the parent story in the sidebar.
You can override the template, the story, or both independently.
Advanced: shared pattern library
If your project uses a shared pattern library (a third root alongside parent and child), add it as patternsRoot and push both theme roots into extraPatternRoots:
options: {
patternsRoot: path.join(SHARED_LIB, 'src/patterns'), // base library
extraPatternRoots: [
path.join(PARENT_THEME, 'src/patterns'), // theme overrides library
path.join(ROOT, 'src/patterns'), // child overrides both
],
}Story deduplication follows the same findStoryFiles() pattern — add a third patterns directory to the relative-path Sets and filter each lower-priority source against all higher-priority sets.