Tailwind CSS in SvelteKit

Aug 23, 2026 SvelteKit

Tailwind CSS puts CSS in class names on the markup. px-4 is padding, text-zinc-900 is a colour, and the build keeps only the names that appear in your files.

SvelteKit runs on Vite, so you add the @tailwindcss/vite plugin. There is no tailwind.config.js. Search results that start with @tailwind base are the old v3 setup. I still hit those by accident.

The SvelteKit tutorial and routing posts leave the shop as unstyled HTML. This one styles the layout and a product card, including a stock button that changes class when you click it.

src/app.css
src/routes/
├── +layout.svelte
└── +page.svelte
vite.config.ts

Step 1: create the project

Reuse the shop from the routing post if you have it. Otherwise install Node 18 or newer and run the Svelte CLI:

npx sv create shop
cd shop
npm install
npm run dev

Pick TypeScript. Skip the add-ons.

Open http://localhost:5173.

This post uses Svelte 5 runes and SvelteKit 2. If sv create gave you SvelteKit 3, $lib may be #lib and Kit config lives on the Vite plugin. The SvelteKit 3 migration guide lists those renames. The Tailwind plugin still goes in vite.config.ts.

Step 2: add Tailwind

Stop the dev server, then from the project root:

npx sv add tailwindcss

The Svelte add-on follows the Tailwind SvelteKit guide. It installs tailwindcss and @tailwindcss/vite, puts the plugin in Vite, and imports a CSS file from the root layout. If Prettier is already there, it also adds prettier-plugin-tailwindcss so class names get sorted on save.

It may ask about @tailwindcss/typography (prose for markdown) and @tailwindcss/forms. Skip both. A product card does not need them. Add prose later with npx sv add tailwindcss="plugins:typography" if a markdown page wants it.

sv add often writes src/routes/layout.css. Ticking Tailwind during sv create writes src/app.css. Both start with @import 'tailwindcss'. This post uses src/app.css so the snippets match the Tailwind guide. If you got layout.css, keep it and import that one. Importing both doubles the stylesheet.

Check that Vite loads Tailwind before SvelteKit:

import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [tailwindcss(), sveltekit()]
});

src/app.css:

@import 'tailwindcss';

Import it once in src/routes/+layout.svelte. A layout wraps every page. Routing already covered {@render children()}.

<script lang="ts">
  import '../app.css';

  let { children } = $props();
</script>

{@render children()}

A layout.css next to the layout is import './layout.css' instead.

npm run dev

Vite reads vite.config.ts when it starts. If you added the plugin while dev was running, restart it.

Step 3: style the layout

Replace src/routes/+layout.svelte. Put the nav and the page width here so every route inherits them.

<script lang="ts">
  import '../app.css';

  let { children } = $props();
</script>

<div class="min-h-screen bg-zinc-50 text-zinc-900">
  <nav class="border-b border-zinc-200 bg-white">
    <div class="mx-auto flex max-w-3xl items-center gap-6 px-4 py-3">
      <a href="/" class="font-semibold">Northside Hardware</a>
      <a href="/" class="text-sm text-zinc-600 hover:text-zinc-900">Home</a>
    </div>
  </nav>

  <main class="mx-auto max-w-3xl px-4 py-8">
    {@render children()}
  </main>
</div>

max-w-3xl mx-auto is a centred column about 48rem wide. flex gap-6 puts the two links in a row. Hover colour lives on the second link only, which is enough for a two-item nav.

If you use VS Code, install Tailwind CSS IntelliSense. Completions for px-4 then show up in .svelte files. The add-on may already have set files.associations so the editor treats .css as Tailwind.

Reload /. The page should have a white bar on a light grey background.

Step 4: a product card

Replace src/routes/+page.svelte:

<script lang="ts">
  let inStock = $state(true);
</script>

<h1 class="text-2xl font-semibold tracking-tight">Hammers</h1>
<p class="mt-1 text-zinc-600">The 16oz claw. Hickory handle.</p>

<article class="mt-6 max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
  <h2 class="font-medium">16oz claw hammer</h2>
  <p class="mt-1 text-sm text-zinc-600">One in the truck, one in the shop.</p>
  <p class="mt-3 text-lg font-semibold">$18</p>

  <button
    class={[
      'mt-4 rounded-md px-3 py-1.5 text-sm font-medium',
      inStock ? 'bg-emerald-700 text-white' : 'bg-zinc-200 text-zinc-500'
    ]}
    onclick={() => (inStock = !inStock)}
  >
    {inStock ? 'In stock' : 'Sold out'}
  </button>
</article>

From Svelte 5.16, class can be an array or an object. Svelte flattens it with clsx. The array is how you keep the padding classes on the button and swap only the colours. class:bg-emerald-700={inStock} still works, but it is the old form. Use the attribute. The class docs show both.

Click the button. The card stays put and the button colours swap.

Do not build the class name in JavaScript:

<!-- Tailwind never sees bg-emerald-700, so the CSS is missing -->
<div class="bg-{color}-700"></div>

The scanner is a text search. It does not run your ternary. inStock ? 'bg-emerald-700' : 'bg-zinc-200' works because both full strings sit in the file. The detecting classes page is the rule, and it is the one people miss.

Step 5: a brand colour

Tailwind’s palette is already in the stylesheet. Shop brown is not. Put your own tokens in @theme in src/app.css:

@import 'tailwindcss';

@theme {
  --color-shop: #b45309;
}

--color-shop becomes bg-shop, text-shop, and border-shop. Put text-shop on the price:

<p class="mt-3 text-lg font-semibold text-shop">$18</p>

Reload. The price should be brown. Change the hex, save, and Vite updates.

There is still no tailwind.config.js. v4 reads this CSS. @config exists if you are dragging a v3 file across. A new app can ignore it.

Step 6: Tailwind inside a style tag

Put the class on the element.

A <style> block is for a selector you would rather not paste onto every heading, or for CSS utilities cannot express. Svelte scopes that CSS to the component. Tailwind utilities stay global. Mixing them is fine.

@apply is the awkward one. The <style> block is a separate CSS file to the compiler, so it does not see your theme unless you point at it:

<h1>Hammers</h1>

<style>
  @reference '../app.css';

  h1 {
    @apply text-2xl font-semibold tracking-tight;
  }
</style>

@reference pulls in tokens and utilities without emitting a second copy of Tailwind. Point it at app.css (or layout.css) so --color-shop exists in that block. @reference 'tailwindcss' only sees the default theme, which is how people lose their brand colour in a style tag.

Do not write @import 'tailwindcss' inside a component <style>. That inlines the whole stylesheet again.

I would leave the heading on the markup. Reach for @apply when a selector is actually easier than a class list.

Things that trip people up

Forgetting the CSS import in +layout.svelte. Utilities in the markup then do nothing. View source. If Vite never sent a stylesheet, the import is missing.

Putting sveltekit() before tailwindcss() in vite.config.ts, or leaving the plugin out. Same blank page. The install guide puts Tailwind first.

Building class names with concatenation. The scanner never sees bg-emerald-700, so it never emits the rule.

A v3 tutorial. @tailwind base, content: ['./src/**/*.svelte'], and postcss.config.cjs belong to that generation. v4 is @import 'tailwindcss' and the Vite plugin.

Editing vite.config.ts and waiting for HMR. Restart npm run dev.

Dropping a large JS file in the repo that Tailwind then scans. Strings like "max-h-40" become real CSS. The global UI script post measured that on this site. @source not in the CSS file excludes the path.

dark: follows prefers-color-scheme until you define a custom variant. A data-theme toggle needs @custom-variant. This page does not set one up.

What to add next

The routing product list and [slug] page can use the same card classes. If the markup starts to repeat, move it into a component under $lib.

A public shop name belongs in $env/static/public. That import is in environment variables.

daisyUI is extra class names on top of this setup. Bits UI is components you import. A global script that also ships class-like strings will inflate the CSS, which is how Preline doubled ours. Measure before you paste one in.