Tailwind CSS v4 with Ember & VITE: Complete Setup Guide
Tailwind CSS makes styling fast and maintainable with utility classes. Combined with Ember's conventions and Vite's speed, you get a productive development experience. Here's everything we learned setting up Tailwind v4 for this blog, from installation to the gotchas that cost us time.
Why We Use Tailwind
- Fast iteration - Style directly in templates without context switching
- Zero config - Tailwind v4 works out of the box, no config file needed
- Scales well - Utility classes keep CSS maintainable as projects grow
- Great typography - The prose plugin makes content beautiful instantly
Let's get it set up.
Installation
First, install Tailwind v4 and its Vite plugin:
pnpm add -D tailwindcss@next @tailwindcss/vite@next
Critical: Use @next to get v4! Without it, you'll get v3 which has completely different setup requirements. Don't make this mistake:
# ❌ Wrong - installs v3
pnpm add -D tailwindcss
# ✅ Correct - installs v4
pnpm add -D tailwindcss@next @tailwindcss/vite@next
Vite Configuration
Update your vite.config.mjs to include the Tailwind plugin:
import { defineConfig } from 'vite';
import { extensions, classicEmberSupport, ember } from '@embroider/vite';
import { babel } from '@rollup/plugin-babel';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [
tailwindcss(),
classicEmberSupport(),
ember(),
babel({
babelHelpers: 'runtime',
extensions,
}),
],
});
CSS Setup
In your app/styles/app.css, add the Tailwind import:
@import 'tailwindcss';
Critical: Update index.html
Gotcha #2: This one cost us significant debugging time. With Tailwind v4's Vite plugin, you need to change how CSS is loaded in index.html.
Remove the Embroider virtual CSS imports:
<!-- ❌ Remove these lines -->
<link integrity="" rel="stylesheet" href="/@embroider/virtual/vendor.css">
<link integrity="" rel="stylesheet" href="/@embroider/virtual/app.css">
And add a direct link to your CSS file:
<!-- ✅ Add this instead -->
<link integrity="" rel="stylesheet" href="./app/styles/app.css">
Your index.html should look like this:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>YourApp</title>
{{content-for "head"}}
{{content-for "head-footer"}}
<link integrity="" rel="stylesheet" href="./app/styles/app.css">
</head>
<body>
{{content-for "body"}}
<script src="/@embroider/virtual/vendor.js"></script>
<script type="module">
import Application from './app/app';
import environment from './app/config/environment';
Application.create(environment.APP);
</script>
{{content-for "body-footer"}}
</body>
</html>
Without this change, Tailwind styles won't load at all. This behavior may get ironed out in future Ember/Embroider updates, but for now it's a required step.
Gotcha #3: If you're migrating from v3, delete your old tailwind.config.js file. The v3 configuration style doesn't work with v4:
// ❌ Wrong - v3 style, doesn't work with v4
module.exports = {
content: ['./app/**/*.{js,ts,gts}'],
theme: { ... }
}
// ✅ Correct - v4 needs no config file!
// Just delete tailwind.config.js entirely
Version Issues to Watch For
The Big One: v4.0.0 vs v4.1.x
When we first installed Tailwind, we got v4.0.0 which had a critical bug:
[vite] Internal server error: Cannot convert undefined or null to object
Plugin: @tailwindcss/vite:generate:serve
The fix: Update to the latest v4.1.x:
pnpm update tailwindcss@latest @tailwindcss/vite@latest
This updated us to v4.1.18, which resolved the issue immediately.
Lesson: Always check for the latest version when working with bleeding-edge packages! You can verify your versions:
pnpm list tailwindcss @tailwindcss/vite
Your First Styled Component
Let's style a simple component to verify everything works:
// app/components/welcome-banner.gts
import Component from '@glimmer/component';
export default class WelcomeBanner extends Component {
<template>
<div class='bg-yellow-50 border-l-4 border-yellow-400 p-4'>
<div class='flex'>
<div class='flex-shrink-0'>
<span class='text-2xl'>🍌</span>
</div>
<div class='ml-3'>
<h3 class='text-sm font-medium text-yellow-800'>
Welcome to Crunchy Bananas!
</h3>
<p class='mt-2 text-sm text-yellow-700'>
This is styled with Tailwind CSS utilities.
</p>
</div>
</div>
</div>
</template>
}
Drop this component into a template and you'll see a nice yellow banner. All styling comes from Tailwind's utility classes - no custom CSS needed.
Adding Typography Support
For blog posts and markdown content, the Typography plugin is essential:
pnpm add -D @tailwindcss/typography
Then add it to your CSS:
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
Gotcha #4: Make sure there's a blank line between the import and the plugin. Some versions of the Tailwind plugin get confused without it.
Your linter might complain:
Unexpected unknown at-rule "@plugin"
This is a false positive - stylelint doesn't recognize Tailwind v4 syntax yet. You can add this to your CSS file to disable the warning:
/* stylelint-disable at-rule-no-unknown */
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
/* stylelint-enable at-rule-no-unknown */
Now you can use the prose classes to make any HTML content look polished:
// app/templates/blog/post.gts
import { htmlSafe } from '@ember/template';
<template>
<article class='prose prose-lg max-w-none prose-headings:text-gray-900 prose-a:text-yellow-600'>
{{htmlSafe @model.content}}
</article>
</template>
The Typography plugin handles all the styling for headings, paragraphs, lists, code blocks, and more. You can customize it with modifiers like prose-headings:text-gray-900 to tweak specific elements.
For more advanced customization:
// app/templates/blog/post.gts
import { htmlSafe } from '@ember/template';
<template>
<article
class='prose prose-lg md:prose-xl max-w-none prose-headings:font-extrabold prose-a:text-yellow-600 prose-code:bg-yellow-50 prose-pre:bg-gray-900'
>
{{htmlSafe @model.content}}
</article>
</template>
The htmlSafe helper is needed here because marked converts your markdown to HTML strings, and Ember needs to know it's safe to render without escaping.
Building a Professional Layout
Navigation with Glass Effect
Here's how we styled our navigation with modern glass morphism:
// app/templates/application.gts
import { LinkTo } from '@ember/routing';
<template>
<nav
class='bg-white/80 backdrop-blur-md border-b border-gray-200 shadow-sm sticky top-0 z-10'
>
<div class='max-w-5xl mx-auto px-6 py-5'>
<LinkTo @route='blog.index' class='text-2xl font-bold text-gray-900'>
<span class='text-yellow-500 font-extrabold'>Crunchy</span>
<span class='font-semibold'>Bananas</span>
</LinkTo>
</div>
</nav>
{{outlet}}
</template>
Key techniques:
backdrop-blur-md- Modern glass effectsticky top-0- Floating navigationbg-white/80- Semi-transparent background- Color accents with
text-yellow-500
Blog Post Cards with Hover Effects
// app/templates/blog/index.gts
import { LinkTo } from '@ember/routing';
<template>
<div class='space-y-6'>
{{#each @model as |post|}}
<article
class='group bg-white rounded-xl shadow-sm hover:shadow-lg transition-all'
>
<div class='p-8'>
<h2 class='text-3xl font-bold mb-3 group-hover:text-yellow-600'>
<LinkTo @route='blog.post' @model={{post.slug}}>
{{post.title}}
</LinkTo>
</h2>
<p class='text-lg text-gray-700'>{{post.excerpt}}</p>
</div>
</article>
{{/each}}
</div>
</template>
Using group hover states creates elegant interactive effects without writing custom CSS. When you hover over the article, the title color changes thanks to group-hover:text-yellow-600.
Adding Dark Mode Support
Tailwind v4 makes dark mode simple. First, add a dark variant to your CSS:
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
@variant dark (&:where(.dark, .dark *));
Then use the dark: prefix in your classes:
<template>
<div class='bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100'>
<h1 class='text-2xl font-bold'>
Hello World
</h1>
<p class='text-gray-600 dark:text-gray-400'>
This text adapts to dark mode.
</p>
</div>
</template>
To toggle dark mode, just add or remove a dark class on your root element:
// app/components/dark-mode-toggle.gts
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
export default class DarkModeToggle extends Component {
@tracked isDark = false;
toggleDarkMode = () => {
this.isDark = !this.isDark;
document.documentElement.classList.toggle('dark', this.isDark);
};
<template>
<button
type='button'
{{on 'click' this.toggleDarkMode}}
class='p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800'
>
{{if this.isDark '☀️' '🌙'}}
</button>
</template>
}
Performance Tips
1. Use Tailwind's Built-in Utilities
Don't write custom CSS when Tailwind has a utility:
<!-- ❌ Custom CSS -->
<style>
.my-card {
padding: 2rem;
border-radius: 0.5rem;
}
</style>
<!-- ✅ Tailwind utilities -->
<div class="p-8 rounded-lg"></div>
2. Leverage JIT Mode
Tailwind v4 uses JIT (Just-In-Time) by default. This means:
- Only CSS you use gets generated
- Arbitrary values work:
w-[347px],text-[#1da1f2] - Build times are faster
- Bundle sizes are smaller
3. Use Prose Modifiers
Instead of overriding prose styles globally, use modifiers:
<!-- ✅ Scoped customization -->
<div class="prose prose-a:text-blue-600 prose-headings:font-bold"></div>
Debugging Tips
Restart Dev Server After Config Changes
After config changes, always restart:
# Kill server (Ctrl+C) then:
pnpm start
Vite's HMR doesn't always catch Tailwind config changes.
Check Browser Console
If styles aren't loading, check for:
- 404 errors on CSS files
- CORS issues
- Plugin errors in the console
Inspect Generated CSS
During development, Vite serves CSS at /@id/__x00__virtual:tailwindcss.css. You can inspect this in DevTools to see what Tailwind generated.
Common Pitfalls Summary
Here are all the gotchas in one place:
- Not using
@nexttag - You'll get v3 instead of v4 - Wrong index.html setup - Must remove Embroider virtual CSS imports and add direct link to app.css
- Old v3 config file - Delete
tailwind.config.jsfor v4 - Missing blank line - Between
@importand@pluginin CSS - Version bugs - Update to v4.1.x or later to avoid bugs
- Linter warnings - Disable stylelint warnings for
@plugin
What We Love About This Stack
- No config file - Less to maintain and think about
- CSS-based configuration - More intuitive for styling
- Faster builds - Noticeably quicker than v3
- Beautiful typography - The prose classes work great
- Modern CSS - Uses native features instead of polyfills
- Fast feedback loop - Vite's HMR + Tailwind's JIT = instant results
What's Next?
You now have a complete Tailwind CSS v4 setup with:
- Core utilities for layout and styling
- Typography plugin for beautiful content
- Dark mode support
- Professional component examples
- Fast HMR via Vite
From here, you can:
- Explore Tailwind's official documentation for all available utilities
- Build custom components using the patterns shown above
- Experiment with arbitrary values and custom CSS variables
Tailwind's utility-first approach means you can iterate quickly without context-switching between files. Everything you need is right in the template.
AI Transparency: This guide was written with Claude Sonnet 4.5 assistance. The content is based on our real experience setting up Tailwind CSS v4 with Ember and Vite for this blog, including all the gotchas and debugging sessions we went through. I provided the technical details, error messages, and code examples from our actual setup, while Claude helped organize the content into a coherent guide and ensure the explanations were clear. All code examples have been tested in production.
Happy styling! 🎨🍌