Skip Navigation

Scott Spence

Shared Tailwind CSS Themes in Svelte Monorepos

7 min read

I’m currently in the process of migrating a large SvelteKit monorepo from Svelte 4 to Svelte 5 with my team. The UI package is still on Svelte 4 and it’s causing a lot of headaches with svelte-check in CI and the on:click and onclick event delegation (UI package is Svelte 4 so a button click event in a Svelte 5 project confuses things slightly). So, I’m updating the UI package to Svelte 5 in a new Svelte 5 package, plus everything is still on Tailwind v3.

Anyways! Whilst creating a new UI package in Svelte 5 also seems like the opportune time to migrate to Tailwind v4 from Tailwind v3, right, right? 😅

So, whilst migrating all the existing components from Svelte 4 to Svelte 5, (Chris Ellis did most of this with Claude Code actually! I’lll mostly be copy pasting!) it makes sense that that components use Tailwind v4 at the same time! So, that’s a package for the UI and a package for the Tailwind theme!

Ok, whilst I’m at it I may as well move where the components are currently viewed as well, from the current home where they live in a MDSveX SvelteKit app over to Stroybook! Makes sense right?? 😅😅

So, that’s the setup! Two new packages and one new app! Stroybook makes sense to me because the stories are self documenting and there’s minimal need for additional changes if the component changes. All the tests and a11y testing can be done in Stroybook meaning there’s zero unit tests needed in the client apps!

Classic plate spinning situation - you know that Malcolm in the Middle gif where Hal goes to change a lightbulb and ends up fixing the entire car? That’s my life right now!

Ok, set up Stroybook in a new app, I followed the sv CLI options to create both a Stroybook starter and create a new UI package with the classic button as the only component, export that for use in Stroybook, then create the Tailwind theme package for use in the UI package and in Stroybook!

The Tailwind theme package is literally two files in a folder! If you’re interested look at the reference repo svelte-storybook-tailwind-monorepo, create the theme.css file:

@theme {
	/* Custom color palette */
	--color-brand-50: #eff6ff;

	/* loads of other stuff */
}

and a package.json:

{
	"name": "@some-org/tailwind-theme",
	"version": "0.1.0",
	"type": "module",
	"exports": {
		"./theme.css": "./src/theme.css"
	},
	"files": ["src"]
}

So, my smooth brain now interprets this as “I can just import the @some-org/tailwind-theme/theme.css file in my SvelteKit app and everything will work, right?” not quite! The UI package components don’t pick up the Tailwind classes!

So, I’ve got the button from the UI package imported into Stroybook, that should “just work” with the picking up the Tailwind classes now?? But it does not!

Turns out it wasn’t Storybook at all (sorry Storybook, I blamed you first). The issue was with how Tailwind v4 handles theme sharing in monorepos.

But here’s the thing in a massive monorepo, there’s probably other factors that could be causing the issue (which I hadn’t diagnosed at this point). I was getting tunnel vision trying to debug it in place.

The minimal repro breakthrough

I should have done this from the start, but hindsight, right? After chatting with Jeppe Reinhold about the issue, I finally got smart and created a minimal reproduction repo to isolate the problem. And boom - immediately obvious what was happening.

In the minimal repro, I went straight to adding the UI component into the main-app instead of trying to isolate it in Storybook first (which is what I was doing in the monorepo). Sometimes you get so focused on the immediate thing you’re working on that you miss the obvious debugging step.

I can apply utility classes directly in the app, like this:

<!-- This works perfectly -->
<div class="bg-brand-500 p-4 text-white">
	Direct utility classes work fine!
</div>

But the UI package components break:

<!-- This component loses all its styles -->
<Button variant="primary">Broken button</Button>

Here’s the thing that took forever to work out and exactly why I’m blogging about it: @source '../../*/src/**/*.{svelte,js,ts}';

That single line is the key to making shared Tailwind v4 themes work in a monorepo.

More on this in a sec!

Why do this though?

This setup makes sense when you have:

  • Multiple apps that need consistent theming
  • Shared component libraries
  • Complex design systems with lots of custom tokens
  • Teams that need to maintain design consistency

It’s probably overkill for simple projects. But if you’re dealing with a large monorepo where design consistency matters, this approach works really well.

The repository

I’ve published the minimal reproduction at github.com/spences10/svelte-storybook-tailwind-monorepo. You can clone it and see exactly how everything fits together.

The key files to look at:

  • packages/tailwind-theme/src/theme.css - For how the @source directive is being used
  • apps/main-app/src/app.css - How apps import the theme
  • packages/ui/src/lib/Button.svelte - Using theme variables in components

The real issue: utility class discovery

Here’s what actually happens when I remove the @source directive. Theme colors work fine for direct utility usage, so, those examples again!

<!-- This works perfectly -->
<div class="bg-brand-500 p-4 text-white">
	Direct utility classes work fine!
</div>

But the UI package components break:

<!-- This component loses all its styles -->
<Button variant="primary">Broken button</Button>

Why this happens

The issue isn’t with importing CSS - that works fine. The problem is utility class discovery. Here’s what I understand now:

With the @source directive:

  1. Tailwind scans ../../*/src/**/*.{svelte,js,ts}
  2. Finds my Button.svelte with classes like bg-brand-600 hover:bg-brand-700
  3. Generates those utility classes in the final CSS
  4. Button components work ✅

Without the @source directive:

  1. Tailwind only processes the current app’s templates
  2. Never scans the UI package’s Button.svelte
  3. Never generates bg-brand-600, hover:bg-brand-700, etc.
  4. Button components have no styles ❌
  5. But direct usage like <div class="bg-brand-500"> still works because Tailwind sees it in the current app

The key insight: Tailwind needs to know which classes to generate. Without @source, it has no idea my UI package components are using those brand utility classes.

Why utility-first components need @source

My Button component uses runtime class composition:

// packages/ui/src/lib/Button.svelte
const variantClasses = {
	primary:
		'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
	secondary:
		'bg-brand-100 text-brand-900 hover:bg-brand-200 active:bg-brand-300',
}

This is utility-first design - composing styles from atomic utility classes. It’s why I use Tailwind instead of plain CSS! But for this to work, Tailwind must:

  1. Know these classes are used (via @source scanning)
  2. Generate them in the final CSS bundle

Without @source, Tailwind never sees my Button component’s template, so it never generates the brand utility classes my component depends on.

The alternative: @layer components (but why would I?)

I could avoid @source by using the @layer components approach:

@layer components {
	.btn-primary {
		@apply bg-brand-500 rounded-lg px-4 py-2 text-white;
	}
}

Then my component would use class="btn-primary" instead of dynamic utility composition. But honestly, if I wanted to write .btn-primary classes, I’d just use regular CSS. The whole point of Tailwind is the utility-first approach! 😅

Why the @source directive is needed

The @source directive is how Tailwind knows which utility classes to generate. Without it, Tailwind only scans the current app’s files and never discovers the classes used in my UI package components.

This isn’t a hack or workaround - it’s the correct way to handle utility-first component libraries in monorepos. The magic glob pattern is exactly what I needed.

Performance optimizations with exclusions

One thing I discovered whilst working on this is that I can improve build performance by excluding files that definitely won’t contain Tailwind classes. Test files, config files, and type definitions are prime candidates for exclusion.

Here’s what I added to the theme package:

/* The main source scanning */
@source '../../*/src/**/*.{svelte,js,ts}';

/* Exclude files that won't have Tailwind classes */
@source not '../../*/src/**/*.test.{js,ts}';
@source not '../../*/src/**/*.spec.{js,ts}';
@source not '../../*/src/**/*.config.{js,ts}';
@source not '../../*/src/**/*.d.ts';

The @source not directive tells Tailwind to skip these files during scanning. In a large monorepo, this can make a noticeable difference to build times because I’m not scanning hundreds of test files and type definitions that will never contain utility classes.

Why include JS/TS files at all? Because my component logic often defines classes in TypeScript:

const variantClasses = {
	primary: 'bg-brand-600 text-white hover:bg-brand-700',
	secondary: 'bg-brand-100 text-brand-900 hover:bg-brand-200',
}

Tailwind treats all files as plain text (no code parsing), so the performance cost is minimal, but the class discovery is essential.

Conclusion

The @source directive with glob patterns is the correct solution for sharing Tailwind v4 themes in utility-first monorepos. It’s not a hack - it’s how I tell Tailwind about cross-package utility usage.

Key takeaways:

  • Import CSS for theme variables - works great for design tokens
  • Use @source for utility discovery - essential for component libraries
  • Optimize with exclusions - exclude test/config files for better performance
  • Embrace utility-first - that’s why we’re using Tailwind!

If you’re setting up a monorepo with shared Tailwind v4 themes, remember to import the CSS for design tokens, use @source for utility discovery, and optimize with exclusions. It’ll save you the debugging I went through!

There's a reactions leaderboard you can check out too.

Copyright © 2017 - 2025 - All rights reserved Scott Spence