The Pragmatic Studio

Adding Tailwind CSS to Phoenix 1.6

April 23, 2022

(Previous versions of this tutorial covered adding Tailwind to Phoenix 1.6 apps using Node and PostCSS and Phoenix 1.4 and 1.5 apps using webpack.)

Recently the Phoenix team released a Tailwind Elixir library that installs and runs a standalone Tailwind CLI for your target system. This means you can now use Tailwind CSS in Phoenix apps without a dependency on Node. 🙌

I’ve been using Tailwind CSS since the early days, and this new approach is by far the best. You get a simpler, faster, more transparent, and more predictable Tailwind integration.

However, I was initially hestitant to make the jump to the Tailwind CLI because it doesn’t include the functionality of the @tailwind/nesting NPM package. And for educational apps, I often use a combination of CSS nesting and Tailwind’s @apply directive to avoid using a lot of Tailwind utility classes in templates. Thankfully I discovered Michael Crumm’s excellent DartSass Elixir library and came up with a way to use it in the CSS build pipeline.

Anyway, here’s how I’m setting up my Phoenix apps with Tailwind and DartSass these days…

1. Install the Tailwind Elixir Library

First, add the tailwind Elixir library dependency to the mix.exs file:

defp deps do
  [
    ...,
    {:tailwind, "~> 0.1", runtime: Mix.env() == :dev}
  ]
end

And install it:

mix deps.get

2. Configure Tailwind

Then in the config/config.exs file, configure the tailwind library to use your preferred version of Tailwind CSS with a default execution profile like so:

config :tailwind,
  version: "3.0.24",
  default: [
    args: ~w(
      --config=tailwind.config.js
      --input=css/app.css
      --output=../priv/static/assets/app.css
    ),
    cd: Path.expand("../assets", __DIR__)
  ]

It takes the assets/css/app.css as the input file and outputs the generated CSS to priv/static/assets/app.css. And it expects a tailwind.config.js file in the assets directory, which we’ll generate next.

3. Install Tailwind CSS

Run the following Mix task to download and install a pre-built binary of the Tailwind CLI for your target platform:

mix tailwind.install

Running this Mix task also generates a config file named tailwind.config.js in the assets directory. Tailwind looks for this file by default for any customizations.

The generated config file is already tailored for Phoenix apps in that the content option includes the standard paths to the JavaScript files, view modules, and template files. Tailwind scans these files looking for any Tailwind utility classes to include in the generated CSS file:

content: [
  './js/**/*.js',
  '../lib/*_web.ex',
  '../lib/*_web/**/*.*ex'
],

The installer also modifies your assets/css/app.css file to import Tailwind CSS base styles, component classes, and utility classes:

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

It also removes the following line so the default Phoenix styles aren’t used:

@import "./phoenix.css";

Finally, the installer removes the following line from the assets/js/app.js file since the Tailwind CLI will be used as the CSS build pipeline rather than esbuild:

import "../css/app.css"

4. Watch For Changes In Development

During development, you need to kick off the CSS build process anytime changes are made to relevant files.

To do that, crack open the config/dev.exs file and search for the list of watchers. By default, there’s an esbuild watcher that invokes the install_and_run function of the Esbuild module to bundle the application’s JavaScript:

watchers: [
  esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
]

Add a tailwind watcher that invokes the install_and_run function of the Tailwind module using the default execution profile to bundle the application’s CSS:

config :my_app, MyAppWeb.Endpoint,
  ...,
  watchers: [
    ...,
    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
  ]

5. Build CSS In Production

You also need to kick off the CSS build process when the application is deployed to a production environment. Basically that means running the same default execution profile as we did in the development environment. But instead of running that command as part of a watcher, the command needs to run whenever the assets.deploy Mix task is run.

To do that, in mix.exs find the "assets.deploy" alias in the aliases function. By default, it looks like this:

"assets.deploy": ["esbuild default --minify", "phx.digest"]

It first runs esbuild with the default execution profile to bundle the js/app.js file. Then it runs the phx.digest task to digest and compress static files.

To build the CSS bundle, add a step that runs tailwind with the default execution profile and the minify option to minify the generated CSS, like so:

"assets.deploy": [
  "esbuild default --minify",
  "tailwind default --minify",
  "phx.digest"
]

6. Style Content Using Tailwind Utility Classes

Now you’re ready fire up the Phoenix server and use Tailwind utility classes to style content. For example, adding this to a view template should give you a Phoenix-colored heading:

<h1 class="text-red-500 text-5xl font-bold text-center">Tailwind CSS</h1>

Then change text-red-500 to text-blue-500, for example, and after saving the file your browser should automatically refresh to show a blue heading.

If you then peek at the generated CSS in priv/static/assets/app.css, you’ll see it includes all the Tailwind base styles and component classes, but only the utility classes you’ve used: text-blue-500, text-5xl, font-bold, and text-center in this case.

Nested CSS with DartSass

Now imagine you need to reuse some styles across multiple template files. For example, let’s suppose it’s a simple content card with a title and description.

To do that, in the css/app.css file you could define card-specific CSS rules using Tailwind’s @apply directive, like so:

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

.card {
  @apply p-4 bg-blue-200 text-blue-800 rounded-lg;
}

.card .title {
  @apply text-2xl tracking-tight font-bold;
}

.card .description {
  @apply pt-4 text-lg font-medium;
}

.card .description a {
  @apply underline text-blue-600;
}

That’ll work, but it’s often handy to use nested rules for related styles like this. Here’s the equivalent:

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

.card {
  @apply p-4 bg-blue-200 text-blue-800 rounded-lg;

  & .title {
    @apply text-2xl tracking-tight font-bold;
  }

  & .description {
    @apply pt-4 text-lg font-medium;

    & a {
      @apply underline text-blue-600;
    }
  }
}

This won’t work, however, because the Tailwind CLI doesn’t include functionality for “flattening out” the nested CSS rules. (The @tailwind/nesting NPM package does this, but it’s not part of the Tailwind CLI.)

This is where the DartSass Elixir library comes into play. It installs and runs a dart-sass implementation for your target system. And, among other things, Sass lets you nest CSS rules.

So instead of processing the CSS file with the Tailwind CLI alone, we also need to process it with DartSass. In other words, we need a two-stage CSS build pipeline. First, we’ll have DartSass compile our CSS to an intermediate file that has all the rules flattened. And then we’ll use that file as the input to the Tailwind CLI to process the @apply directives.

Let’s go…

1. Prepare for Sass

First, rename app.css to app.scss.

And in that file replace the Tailwind @import lines with the following @tailwind directives:

@tailwind base;
@tailwind components;
@tailwind utilities;

Then in the assets/js/app.js file, make sure to remove (or comment) the following line so that esbuild doesn’t try to generate CSS:

import "../css/app.css"

2. Install the DartSass Elixir Library

Then add the dart_sass dependency to your mix.exs file:

defp deps do
  [
    ...,
    {:dart_sass, "~> 0.5", runtime: Mix.env() == :dev}
  ]
end

And install it:

mix deps.get

3. Configure DartSass

Then in your config/config.exs file, configure the dart_sass library to use your preferred version of dart-sass with a default execution profile like so:

config :dart_sass,
  version: "1.49.11",
  default: [
    args: ~w(css/app.scss ../priv/static/assets/app.css.tailwind),
    cd: Path.expand("../assets", __DIR__)
  ]

This uses your assets/css/app.scss as the input file and drops the compiled CSS into the priv/static/assets/app.css.tailwind file. That’ll be the first stage of the CSS build process.

Then change the tailwind configuration to use the intermediate priv/static/assets/app.css.tailwind file as its input and spit out the final CSS in /priv/static/assets/app.css:

config :tailwind,
  version: "3.0.24",
  default: [
    args: ~w(
      --config=tailwind.config.js
      --input=../priv/static/assets/app.css.tailwind
      --output=../priv/static/assets/app.css
    ),
    cd: Path.expand("../assets", __DIR__)
  ]

So the output of DartSass is used as the input to the Tailwind CLI. That’ll be the second stage of the CSS build process.

4. Install DartSass

Run the following Mix task to download and install a pre-built binary of the dart-sass for your target system:

mix sass.install

5. Watch For Changes In Development

In config/dev.exs, add a sass watcher to the watchers list:

config :my_app, MyAppWeb.Endpoint,
  ...,
  watchers: [
    ...,
    tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
    sass: {DartSass, :install_and_run, [:default, ~w(--watch)]}
  ]

6. Run the Pipeline In Production

In mix.exs, add a step to the "assets.deploy" alias that runs sass with the default execution profile before the tailwind step, like so:

"assets.deploy": [
  "esbuild default --minify",
  "sass default",
  "tailwind default --minify",
  "phx.digest"
]

7. Pre-Build Assets

Now, the first time you fire up the Phoenix server, you’ll see this:

Specified input file ../priv/static/assets/app.css.tailwind does not exist.
Compiled css/app.scss to ../priv/static/assets/app.css.tailwind.

The Tailwind CLI tried to use the intermediate app.css.tailwind file as input, but it didn’t yet exist because DartSass ran after Tailwind. Restarting the server clears up the problem, but that’s clunky. And reordering the watchers doesn’t fix it.

One way to get around this first-time server boot problem is to pre-build all the assets before running the server. To do that, in mix.exs add a new "assets.build" alias that builds the assets by running sass before tailwind, like so:

"assets.build": [
  "esbuild default",
  "sass default",
  "tailwind default"
],

And then you’ll need to make sure to run mix assets.build before firing up the server the first time.

Alternatively, since it’s common to run mix setup on new Phoenix projects, you can add a step to the setup alias that runs the "assets.build" task:

setup: ["deps.get", "ecto.setup", "assets.build"],

8. Use Nested Styles

Now in your template files you should be able to use the card, title, and description classes to style a content card, like so:

<div class="card">
  <div class="title">
    Phoenix + Tailwind
  </div>
  <div class="description">
    A fiery wind at your back?
    (<a href="#">click me</a>)
  </div>
</div>

Anyway, hopefully that helps you get everything set up quickly so you can get on to making cool stuff!

🔥 Free Phoenix LiveView Course!

Get my free Phoenix LiveView course and start building interactive, real-time features using all the facets of LiveView. This course has everything you need, assembled in the right order, and in one place! đź‘Ť

Phoenix LiveView Course