While building a UI library for SvelteKit, I wanted the consumer setup to feel as simple as possible:
import { Button } from '@svkit/ui';
No extra CSS imports.
No Tailwind setup.
No configuration.
Just import the component and everything works.
At first, this sounded straightforward.
It wasn't.
After several experiments, I ended up discovering something much deeper than a styling issue:
CSS is not really part of the JavaScript module system.
And modern bundlers only make it feel like it is.
Context
I'm currently building svkit (coming soon :)), a Svelte component library primarily built for my own projects and experiments around component architecture, styling systems, and developer experience.
The library is split into two packages:
@svkit/ui
@svkit/styles
-
@svkit/uicontains the Svelte components and logic. -
@svkit/stylescontains the design tokens, themes, and component styles.
Internally, the styling system uses Tailwind utilities and @apply, but the goal is to distribute fully compiled CSS so consumers don't need Tailwind at all.
The ideal setup I wanted was this:
import { Button } from '@svkit/ui';
Without requiring:
@import '@svkit/styles';
That one extra CSS import felt unnecessary.
So I started experimenting.
The Intuitive Solution Everyone Tries
The most obvious approach is:
import './styles.css';
This works perfectly inside app source files.
For example, inside a Svelte component:
<script>
import './button.css';
</script>
Vite intercepts the CSS import, transforms it, injects styles correctly, and everything works.
So naturally, I expected the same thing to work inside a published package.
But crossing package boundaries changes everything.
Experiment 1 — Static CSS Imports
I tried adding a static CSS import directly inside the distributed JS entry:
// packages/styles/dist/index.js
import './styles.css';
Result
| Environment | Result |
|---|---|
| Client (Vite dev/build) | Works |
| SSR (Node.js ESM) | Crashes |
The SSR error:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css"
At first this was confusing.
Why does CSS importing work perfectly inside app source files, but fail inside a package?
The answer is subtle.
Vite only transforms CSS imports inside source files that belong to the application's module graph.
App Source Graph
↓
Vite owns the transform pipeline
↓
CSS imports work
Package Export Boundary
↓
Node.js sees raw .css imports
↓
SSR crashes
Once a file is resolved through package.json exports, Vite treats it as a prebuilt dependency.
That means this:
import './styles.css';
is eventually seen directly by Node.js during SSR.
And Node.js does not understand CSS as an ESM module.
Experiment 2 — Dynamic import()
The next idea was:
if (typeof window !== 'undefined') {
import('./styles.css');
}
This avoided the SSR crash because the import never executes on the server.
Problem solved?
Not really.
Result
| Environment | Result |
|---|---|
| Client | Massive FOUC |
| SSR | Works |
The issue is that dynamic import() is asynchronous.
The browser renders the page before the CSS finishes loading.
The result:
- unstyled components during hydration
- visible layout shift
- 5–10 seconds of Flash of Unstyled Content in some cases
Technically functional.
Architecturally terrible.
Experiment 3 — Precompiled Tailwind CSS
At this point, I thought maybe Tailwind was the real problem.
What surprised me most was that the issue had nothing to do with Tailwind itself.
Even after fully compiling the CSS into plain output, the exact same architectural problem remained.
Since the styles used @apply, I tried fully compiling everything during build time:
npx @tailwindcss/cli --input compile-entry.css --output dist/styles.css
This generated fully resolved CSS:
- no
@apply - no Tailwind dependency
- plain CSS output
The final file was around 133KB.
But the problem still remained.
Because the issue was never Tailwind.
The issue was:
JavaScript importing CSS across package boundaries during SSR.
From Node.js's perspective, precompiled CSS is still just a .css file.
And Node.js still cannot execute:
import './styles.css';
inside an SSR dependency graph.
Experiment 4 — Inlining CSS into JavaScript
Another approach was serializing the CSS directly into JavaScript:
export const css = `/* compiled CSS */`;
Then injecting it at runtime.
Technically possible.
But the trade-offs were ugly:
- huge JS payloads
- duplicated CSS across components
- CSP concerns
- runtime style injection
- loss of caching efficiency
- mixing styling concerns into JS runtime
It felt like fighting the platform.
Experiment 5 — A Custom Vite Plugin
I also considered creating a custom Vite plugin that would intercept imports and automatically inject CSS alongside components.
Something like:
import { Button } from '@svkit/ui';
would internally transform into:
import '@svkit/styles/button.css';
import { Button } from '@svkit/ui';
This was probably the closest thing to the original dream.
But it introduced a new category of problems:
- plugin maintenance burden
- SSR adapter edge cases
- coupling consumers to Vite
- ecosystem fragility
- framework compatibility issues
At that point, the complexity no longer felt justified.
The Realization
Eventually, I realized something important:
CSS is not actually part of the JavaScript module system.
Modern bundlers only create the illusion that it is.
When you write:
import './foo.css';
inside app source code, Vite transforms it for you.
But that behavior is not native JavaScript.
It's bundler-controlled behavior.
And once package boundaries, SSR runtimes, and prebuilt dependencies enter the picture, the illusion starts to break.
Why Explicit CSS Imports Still Exist
At this point, I started looking at how mature UI libraries handle styles.
And almost all of them settled on the same pattern:
@import '@svkit/styles';
or:
import 'bootstrap/dist/bootstrap.css';
or:
@import 'daisyui';
Not because library authors are lazy.
But because explicit CSS imports are:
- SSR-safe
- synchronous
- predictable
- cache-friendly
- bundler-agnostic
- framework-agnostic
- easy to reason about
And most importantly:
they align with how the platform actually works.
The Architecture I Ended Up Choosing
Instead of hiding CSS behind JavaScript imports, svkit now uses an explicit style entry:
/* app.css */
@import '@svkit/styles';
And components are imported normally:
import { Button } from '@svkit/ui';
It's one extra line.
But in return, the architecture becomes:
- stable
- SSR-safe
- predictable
- maintainable
- adapter-safe
- framework-independent
And honestly, after all the experiments, it feels like the correct trade-off.
Final Thoughts
One of the most interesting things about modern frontend tooling is how much complexity can hide behind seemingly simple developer experience goals.
"Just import the component and let the styles load automatically" sounds trivial.
But underneath that sentence are:
- ESM semantics
- SSR runtimes
- asset pipelines
- bundler ownership
- synchronous rendering constraints
- hydration timing
- dependency externalization
The deeper I explored this problem, the more I realized:
modern bundlers do not own the entire runtime.
And CSS still behaves fundamentally differently from JavaScript.
The web platform has always treated CSS and JavaScript as fundamentally different systems.
Modern tooling can blur that boundary for a while.
But eventually, package boundaries, SSR runtimes, and real-world distribution models expose the difference again.
Which is probably why, even in 2026, UI libraries still need explicit CSS imports.
Top comments (0)