DEV Community

self-dev
self-dev

Posted on

Why UI Libraries Still Need Explicit CSS Imports

While building a UI library for SvelteKit, I wanted the consumer setup to feel as simple as possible:

import { Button } from '@svkit/ui';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • @svkit/ui contains the Svelte components and logic.
  • @svkit/styles contains 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';
Enter fullscreen mode Exit fullscreen mode

Without requiring:

@import '@svkit/styles';
Enter fullscreen mode Exit fullscreen mode

That one extra CSS import felt unnecessary.

So I started experimenting.


The Intuitive Solution Everyone Tries

The most obvious approach is:

import './styles.css';
Enter fullscreen mode Exit fullscreen mode

This works perfectly inside app source files.

For example, inside a Svelte component:

<script>
  import './button.css';
</script>
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Once a file is resolved through package.json exports, Vite treats it as a prebuilt dependency.

That means this:

import './styles.css';
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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 */`;
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

would internally transform into:

import '@svkit/styles/button.css';
import { Button } from '@svkit/ui';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

or:

import 'bootstrap/dist/bootstrap.css';
Enter fullscreen mode Exit fullscreen mode

or:

@import 'daisyui';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

And components are imported normally:

import { Button } from '@svkit/ui';
Enter fullscreen mode Exit fullscreen mode

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)