ember tailwind vite css tutorial

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

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 effect
  • sticky top-0 - Floating navigation
  • bg-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:

  1. Not using @next tag - You'll get v3 instead of v4
  2. Wrong index.html setup - Must remove Embroider virtual CSS imports and add direct link to app.css
  3. Old v3 config file - Delete tailwind.config.js for v4
  4. Missing blank line - Between @import and @plugin in CSS
  5. Version bugs - Update to v4.1.x or later to avoid bugs
  6. Linter warnings - Disable stylelint warnings for @plugin

What We Love About This Stack

  1. No config file - Less to maintain and think about
  2. CSS-based configuration - More intuitive for styling
  3. Faster builds - Noticeably quicker than v3
  4. Beautiful typography - The prose classes work great
  5. Modern CSS - Uses native features instead of polyfills
  6. 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! 🎨🍌

If this article helped you ship code, consider sponsoring my work.

No perks. No ads. Just support for writing that doesn't waste your time.