Show Navigation

Tailwind CSS 4 with Grails 8

Wire Tailwind CSS 4 into a Grails 8 GSP application with class-based dark mode and a small @apply component layer, all driven from the Gradle build.

Authors: James Fredley

Grails Version: 8

1 Getting Started

In this guide you will wire Tailwind CSS 4 into a fresh Apache Grails 8 application so every GSP view can be styled with utility classes, with class-based dark mode and a small @apply component layer for the patterns that repeat.

The end state stays close to the Grails 8 web starter. GSPs still render through the standard layout tags, the asset pipeline still serves application.css, and Gradle owns the extra Tailwind compile step.

This guide targets Apache Grails 8.

Files touched in this chapter:

  • None

1.1 What You Will Build

By the end of the guide your application will:

  • Render grails-app/views/layouts/main.gsp and grails-app/views/index.gsp with Tailwind 4 utility classes instead of the default Bootstrap 5 classes the starter ships with.

  • Compile src/main/css/input.css (the Tailwind entry point) into grails-app/assets/stylesheets/app.css via a tailwindBuild Gradle task that runs before processResources and compileGroovy. The asset pipeline then bundles, fingerprints, and gzips that file into the application.css bundle the layout serves.

  • Toggle dark mode at runtime by adding the dark class on <html>, persisting the choice to localStorage so reloads do not flash light-to-dark.

  • Expose three reusable component classes (btn-primary, card, nav-link) defined in the @layer components block of input.css and used directly from GSP markup.

The result is a small but production-shaped baseline you can copy into any Grails 8 app that still wants the GSP view layer but a modern utility-first CSS pipeline.

Files touched in this chapter:

  • None

1.2 What You Will Need

To complete this guide you will need:

  • JDK 21

  • Node.js 20+

  • npm 10+

  • A Bash, PowerShell, or cmd shell capable of running the included Gradle wrapper (./gradlew or gradlew.bat)

  • About 30 minutes

Files touched in this chapter:

  • None

1.3 How to Complete the Guide

You can either type the code in this guide as you read or skip ahead and clone the finished sample app from the upstream repository:

git clone -b grails8 https://github.com/grails-guides/grails-tailwindcss.git
cd grails-tailwindcss/complete
./gradlew bootRun

The repository contains two top-level directories:

  • initial/ - the starting Grails 8 project, generated from start.grails.org with no customisations applied.

  • complete/ - the same project with all Tailwind wiring from this guide already in place.

Each chapter ends with the file paths it touches relative to the project root, so you can match what you typed against the complete/ reference.

Files touched in this chapter:

  • None

2 Creating the Application

Generate a fresh Apache Grails 8 web application from start.grails.org. This is the same forge that produced the initial/ tree in the upstream repository.

Use the web profile. That keeps you on the same starter layout, index page, asset-pipeline manifest, and dependency set that Grails 8 ships from grails-profiles/web.

Files touched in this chapter:

  • build.gradle

  • grails-app/views/layouts/main.gsp

  • grails-app/views/index.gsp

2.1 Download a Grails 8 Starter

Open start.grails.org, pick the web profile, name the application tailwind, set the package to example, choose JDK 21, and download the generated zip. Unzip it and cd into the tailwind directory.

Verify it boots before you go further:

./gradlew bootRun

Open http://localhost:8080/ in a browser and you should see the default Grails landing page. Stop the application with Ctrl+C once you have confirmed it runs.

The forge’s default build.gradle ships with compileJava.options.release = 17 even when JDK 21 is requested. The complete/ tree in the upstream repo already patches this to 21; do the same in your local checkout if you want JDK 21 source-level features.

Files touched in this chapter:

  • build.gradle

  • settings.gradle

  • grails-app/views/layouts/main.gsp

  • grails-app/views/index.gsp

3 Installing Tailwind CSS 4

Tailwind 4 ships as the npm package tailwindcss, with a separate @tailwindcss/cli package for the standalone command-line compiler that we will drive from Gradle. Add a small package.json at the project root:

package.json
{
  "name": "grails-tailwindcss",
  "version": "1.0.0",
  "private": true,
  "description": "Tailwind CSS 4 build wiring for the grails-tailwindcss/v8 sample app.",
  "engines": {
    "node": ">=20",
    "npm": ">=10"
  },
  "scripts": {
    "build": "tailwindcss -i src/main/css/input.css -o grails-app/assets/stylesheets/app.css --minify",
    "watch": "tailwindcss -i src/main/css/input.css -o grails-app/assets/stylesheets/app.css --watch"
  },
  "devDependencies": {
    "@tailwindcss/cli": "^4.0.0",
    "tailwindcss": "^4.0.0"
  }
}

Then run npm install once to populate node_modules/ and create package-lock.json:

npm install

The build and watch scripts call the tailwindcss binary that @tailwindcss/cli exposes, while the Gradle task you will add in a later chapter runs the same compiler through npx @tailwindcss/cli. That keeps the npm side and Gradle side aligned.

Files touched in this chapter:

  • package.json

  • package-lock.json

4 Configuring Tailwind for Grails

Tailwind 4 introduced a CSS-first configuration model. Most projects no longer need a tailwind.config.js file at all. Theme tokens, content sources, and custom variants are declared in your CSS entry point with the @theme, @source, and @custom-variant at-rules.

For a Grails app that means three knobs to set:

  • Where to scan for class names. Tailwind 4’s scanner does not know about .gsp files out of the box, and it is conservative about reading utility-class string literals from .groovy source. Both have to be added with explicit @source directives.

  • How to opt into class-based dark mode. The default dark variant follows prefers-color-scheme. To let the user pick a theme and persist it, override the variant so dark: utilities apply when an ancestor has the dark class.

  • Reusable component classes (optional). Tailwind 4 adds @utility for custom utilities, but a small @layer components block is still a good fit when you want a few named classes that read well in GSPs.

All three live in src/main/css/input.css, which we will write next.

Files touched in this chapter:

  • src/main/css/input.css

4.1 The input.css Entry Point

Create the Tailwind entry point at src/main/css/input.css:

src/main/css/input.css
/*
 * Tailwind CSS 4 entry point.
 *
 * Tailwind 4 uses CSS-first configuration: the @import below pulls in the
 * framework, @source tells the JIT scanner where to find class names, and
 * @custom-variant lets us opt into class-based dark mode without a separate
 * tailwind.config.js. See guides/grails-tailwindcss/v8/guide/configureTailwind.adoc.
 */

@import "tailwindcss";

/*
 * Where to scan for class names. Tailwind 4 auto-detects most file types,
 * but does not know about .gsp out of the box and is conservative about
 * scanning .groovy files for utility-class string literals (e.g. classes
 * passed via tag-attribute composition in controllers or tag libraries).
 */
@source "../../../grails-app/views";
@source "../../../grails-app/controllers";
@source "../../../grails-app/services";
@source "../../../grails-app/taglib";
@source "../../../src/main/groovy";

/*
 * Dark mode via a class on <html>. Toggled at runtime in main.gsp.
 * The default Tailwind 4 dark variant follows prefers-color-scheme; this
 * override lets a user persist their choice across sessions.
 */
@custom-variant dark (&:where(.dark, .dark *));

/*
 * A small component layer: classes you can spell once and reuse across
 * GSPs without the full utility chain. Good fit for buttons, cards, and
 * form-control wrappers that the Grails Fields plugin will eventually
 * render around your inputs.
 */
@layer components {
    .btn-primary {
        @apply inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-offset-gray-900;
    }
    .card {
        @apply rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800;
    }
    .nav-link {
        @apply text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white;
    }
}[]

Four things in this file are worth highlighting:

  • @import "tailwindcss"; pulls in the framework. In v3 you wrote three separate @tailwind base; @tailwind components; @tailwind utilities; directives; v4 collapses all three into the single @import.

  • The @source directives tell the scanner exactly where to look. The starter the forge produces puts views under grails-app/views, controllers under grails-app/controllers, services under grails-app/services, tag libraries under grails-app/taglib, and helper code under src/main/groovy.

  • The @custom-variant dark (&:where(.dark, .dark *)); line redefines dark: so it applies whenever an ancestor (typically <html>) carries the dark class. Without this override the v4 default uses prefers-color-scheme, which honours the OS theme but does not let the user override it.

  • The @layer components block at the bottom defines .btn-primary, .card, and .nav-link. These are referenced from main.gsp and index.gsp in later chapters.

Files touched in this chapter:

  • src/main/css/input.css

5 Wiring Tailwind into the Gradle Build

A small block at the bottom of build.gradle is everything the build needs:

build.gradle
def npxCmd = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'npx.cmd' : 'npx'
def npmCmd = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'npm.cmd' : 'npm'

def npmInstall = tasks.register('npmInstall', Exec) {
    description = 'Install npm dependencies for Tailwind CSS 4'
    group = 'tailwind'
    inputs.files('package.json', 'package-lock.json')
    outputs.dir('node_modules')
    commandLine npmCmd, 'install', '--no-fund', '--no-audit'
}

def tailwindBuild = tasks.register('tailwindBuild', Exec) {
    description = 'Compile src/main/css/input.css into grails-app/assets/stylesheets/app.css'
    group = 'tailwind'
    dependsOn npmInstall
    inputs.files('package.json', 'package-lock.json', 'src/main/css/input.css')
    inputs.dir('grails-app/views')
    inputs.dir('grails-app/controllers')
    inputs.dir('grails-app/services')
    inputs.dir('grails-app/taglib')
    inputs.dir('src/main/groovy')
    outputs.file('grails-app/assets/stylesheets/app.css')
    commandLine npxCmd, '@tailwindcss/cli',
            '-i', 'src/main/css/input.css',
            '-o', 'grails-app/assets/stylesheets/app.css',
            '--minify'
}

tasks.named('processResources').configure { dependsOn tailwindBuild }
tasks.named('compileGroovy').configure { dependsOn tailwindBuild }

And the asset manifest becomes a one-line include of the generated Tailwind output:

grails-app/assets/stylesheets/application.css
/*
 * Asset-pipeline manifest. Pull the Tailwind CLI output into the canonical
 * application.css bundle the layout already serves.
 *
 *= require app
 */

Two Exec tasks do the work:

  • npmInstall runs npm install and caches its result. The node_modules/ output plus the package.json and package-lock.json inputs mean Gradle reruns it only when the Node dependency graph changes.

  • tailwindBuild calls npx @tailwindcss/cli with the same -i and -o paths used by the npm scripts. Its inputs cover the entry CSS and every directory that the @source directives scan.

The two tasks.named lines hook tailwindBuild into the standard Grails build graph so plain ./gradlew bootRun, assemble, or bootJar regenerates app.css before resources and Groovy sources compile. For iterative work without Gradle in the loop, run npm run watch in a second shell.

The compiled app.css lands inside grails-app/assets/stylesheets/, which is the asset pipeline’s source directory. The layout still asks for application.css via <asset:stylesheet>, but the manifest now pulls in the Tailwind output instead of the starter’s Bootstrap CSS. app.css itself is regenerated on every build, so it is git-ignored.

Files touched in this chapter:

  • build.gradle

  • grails-app/assets/stylesheets/application.css

6 Applying Tailwind to the GSP Layout

With the Tailwind pipeline in place, replace the layout markup. The starter ships a Bootstrap 5 layout in grails-app/views/layouts/main.gsp; rewrite it with Tailwind utilities and a small flex-based navbar:

grails-app/views/layouts/main.gsp
<!doctype html>
<html lang="en" class="h-full">
<head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <title><g:layoutTitle default="Grails"/></title>
    <asset:link rel="icon" href="favicon.ico" type="image/x-ico"/>
    <asset:stylesheet src="application.css"/>

    <%-- Pre-paint dark-mode resolution: read the user's saved choice (or
         system preference) and apply the `dark` class on <html> BEFORE
         the body renders. This avoids the light-to-dark flash on reload. --%>
    <script>
        (function () {
            var saved = localStorage.getItem('theme');
            var prefers = window.matchMedia('(prefers-color-scheme: dark)').matches;
            if (saved === 'dark' || (!saved && prefers)) {
                document.documentElement.classList.add('dark');
            }
        })();
    </script>
    <g:layoutHead/>
</head>

<body class="h-full bg-gray-50 text-gray-900 antialiased dark:bg-gray-900 dark:text-gray-100">

<nav class="border-b border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
    <div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8">
        <a class="flex items-center gap-2" href="${request.contextPath}/">
            <asset:image class="h-8 w-auto" src="grails.svg" alt="Grails Logo"/>
            <span class="text-base font-semibold">Grails + Tailwind CSS</span>
        </a>
        <div class="flex items-center gap-6">
            <a href="https://grails.apache.org/docs/" class="nav-link" target="_blank" rel="noopener">Docs</a>
            <a href="https://grails.apache.org/community.html" class="nav-link" target="_blank" rel="noopener">Community</a>
            <button id="theme-toggle"
                    type="button"
                    aria-label="Toggle dark mode"
                    class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
                <span aria-hidden="true" class="dark:hidden">&#9788;</span>
                <span aria-hidden="true" class="hidden dark:inline">&#9789;</span>
            </button>
        </div>
    </div>
</nav>

<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
    <g:layoutBody/>
</div>

<footer class="mt-12 border-t border-gray-200 bg-white py-8 dark:border-gray-700 dark:bg-gray-800">
    <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
        <p class="text-sm text-gray-500 dark:text-gray-400">
            Built with Apache Grails 8 and Tailwind CSS 4. See the
            <a class="font-medium text-blue-600 hover:underline dark:text-blue-400"
               href="https://grails.apache.org/guides/grails-tailwindcss/8/guide/index.html">
                guide
            </a> for the full source.
        </p>
    </div>
</footer>

<script>
    document.getElementById('theme-toggle').addEventListener('click', function () {
        var html = document.documentElement;
        html.classList.toggle('dark');
        localStorage.setItem('theme', html.classList.contains('dark') ? 'dark' : 'light');
    });
</script>
<asset:javascript src="application.js"/>
</body>
</html>[]

A few patterns are worth pointing at:

  • The <asset:stylesheet src="application.css"/> taglib resolves to the bundled, fingerprinted application.css URL at runtime. The manifest you edited in the previous chapter is what tells the pipeline to include the compiled Tailwind app.css in that bundle. The layout never references app.css directly.

  • The layout keeps the starter’s favicon.ico link and application.js include. That means the page still uses the stock asset-pipeline conventions even though the CSS is now Tailwind-driven.

  • The outer <body> carries bg-gray-50 dark:bg-gray-900 so the colour scheme flips at the top level.

  • The navbar uses mx-auto max-w-7xl plus a flex row, replacing the Bootstrap navbar component with a hand-rolled equivalent that is easier to customise.

  • The footer link is the text-blue-600 hover:underline dark:text-blue-400 triplet that you will repeat all over the app once you commit to the utility-first approach.

The welcome page itself follows the same recipe:

grails-app/views/index.gsp
<%@ page import="grails.util.Environment"%>
<%@ page import="org.springframework.boot.SpringBootVersion"%>
<%@ page import="org.springframework.core.SpringVersion"%>
<g:set var="pluginManager" bean="pluginManager"/>
<g:set var="numControllers" value="${grailsApplication.controllerClasses.size()}"/>
<g:set var="numDomains" value="${grailsApplication.domainClasses.size()}"/>
<g:set var="numPlugins" value="${pluginManager.allPlugins.size()}"/>
<!doctype html>
<html>
<head>
    <title>Welcome to Grails + Tailwind</title>
    <meta name="layout" content="main"/>
</head>
<body>

<main id="content" role="main" class="space-y-8">

    <section class="card">
        <h1 class="text-3xl font-semibold tracking-tight">
            Welcome to Grails + Tailwind CSS
        </h1>
        <p class="mt-2 max-w-2xl text-base text-gray-600 dark:text-gray-400">
            This page is rendered by a Grails 8 GSP layout and styled entirely with
            Tailwind CSS 4 utilities. The dark-mode toggle in the navbar persists
            across reloads via <code class="rounded bg-gray-100 px-1 py-0.5 text-sm dark:bg-gray-700">localStorage</code>.
        </p>
        <div class="mt-6 flex flex-wrap gap-3">
            <a href="https://grails.apache.org/guides/grails-tailwindcss/8/guide/index.html"
               class="btn-primary"
               target="_blank" rel="noopener">
                Read the guide
            </a>
            <a href="https://github.com/grails-guides/grails-tailwindcss/tree/grails8/complete"
               class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
               target="_blank" rel="noopener">
                View source
            </a>
        </div>
    </section>

    <section class="grid grid-cols-1 gap-6 md:grid-cols-3">
        <div class="card">
            <h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                Runtime
            </h2>
            <dl class="mt-4 space-y-2 text-sm">
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Grails</dt>
                    <dd class="font-medium tabular-nums"><g:meta name="info.app.grailsVersion"/></dd>
                </div>
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Spring Boot</dt>
                    <dd class="font-medium tabular-nums">${SpringBootVersion.getVersion()}</dd>
                </div>
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Spring</dt>
                    <dd class="font-medium tabular-nums">${SpringVersion.getVersion()}</dd>
                </div>
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Groovy</dt>
                    <dd class="font-medium tabular-nums">${GroovySystem.getVersion()}</dd>
                </div>
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">JVM</dt>
                    <dd class="font-medium tabular-nums">${System.getProperty('java.version')}</dd>
                </div>
            </dl>
        </div>

        <div class="card">
            <h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                Environment
            </h2>
            <dl class="mt-4 space-y-2 text-sm">
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">App name</dt>
                    <dd class="font-medium"><g:meta name="info.app.name"/></dd>
                </div>
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Mode</dt>
                    <dd class="font-medium">${Environment.current.name}</dd>
                </div>
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Reloading</dt>
                    <dd class="font-medium">
                        <g:if test="${Environment.reloadingAgentEnabled}">
                            <span class="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/40 dark:text-green-300">
                                Active
                            </span>
                        </g:if>
                        <g:else>
                            <span class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-300">
                                Inactive
                            </span>
                        </g:else>
                    </dd>
                </div>
            </dl>
        </div>

        <div class="card">
            <h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
                Artefacts
            </h2>
            <dl class="mt-4 space-y-2 text-sm">
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Controllers</dt>
                    <dd class="font-medium tabular-nums">${numControllers}</dd>
                </div>
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Domains</dt>
                    <dd class="font-medium tabular-nums">${numDomains}</dd>
                </div>
                <div class="flex justify-between">
                    <dt class="text-gray-600 dark:text-gray-400">Plugins</dt>
                    <dd class="font-medium tabular-nums">${numPlugins}</dd>
                </div>
            </dl>
        </div>
    </section>

</main>
</body>
</html>[]

Reload http://localhost:8080 after ./gradlew bootRun and you should see the Tailwind-styled cards and the dark-mode toggle in the top right.

Files touched in this chapter:

  • grails-app/views/layouts/main.gsp

  • grails-app/views/index.gsp

7 Class-Based Dark Mode

The @custom-variant dark (&:where(.dark, .dark *)); line in input.css made every dark:-prefixed utility opt-in on the presence of the dark class on <html>. Two small bits of markup in main.gsp flip it on and off.

The first is a pre-paint script in <head> that runs before any styled content reaches the screen:

grails-app/views/layouts/main.gsp
<%-- Pre-paint dark-mode resolution: read the user's saved choice (or
     system preference) and apply the `dark` class on <html> BEFORE
     the body renders. This avoids the light-to-dark flash on reload. --%>
<script>
    (function () {
        var saved = localStorage.getItem('theme');
        var prefers = window.matchMedia('(prefers-color-scheme: dark)').matches;
        if (saved === 'dark' || (!saved && prefers)) {
            document.documentElement.classList.add('dark');
        }
    })();
</script>[]

It reads the user’s saved choice (or the OS preference if the user has not chosen) and adds the dark class to <html> synchronously. Because it runs before the body renders there is no light-to-dark flash on reload.

The second is the toggle button in the navbar plus a click handler at the end of the body that flips the class and writes the new value to localStorage:

grails-app/views/layouts/main.gsp
<button id="theme-toggle"
        type="button"
        aria-label="Toggle dark mode"
        class="inline-flex h-9 w-9 items-center justify-center rounded-md border border-gray-300 bg-white text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
    <span aria-hidden="true" class="dark:hidden">&#9788;</span>
    <span aria-hidden="true" class="hidden dark:inline">&#9789;</span>
</button>[]
grails-app/views/layouts/main.gsp
<script>
    document.getElementById('theme-toggle').addEventListener('click', function () {
        var html = document.documentElement;
        html.classList.toggle('dark');
        localStorage.setItem('theme', html.classList.contains('dark') ? 'dark' : 'light');
    });
</script>[]

Notice the icon swap: the sun glyph is hidden in dark mode (dark:hidden), the moon glyph is hidden in light mode (hidden dark:inline). The same dark:/non-dark: pairing pattern works for any element whose appearance depends on theme.

Files touched in this chapter:

  • src/main/css/input.css

  • grails-app/views/layouts/main.gsp

8 Reusable Component Classes with @apply

Two patterns repeat across the welcome page: the primary action button and the white card with a header. Once a utility chain shows up four or five times across GSPs it is worth promoting to a named class in the @layer components block.

The relevant section of input.css:

src/main/css/input.css
/*
 * A small component layer: classes you can spell once and reuse across
 * GSPs without the full utility chain. Good fit for buttons, cards, and
 * form-control wrappers that the Grails Fields plugin will eventually
 * render around your inputs.
 */
@layer components {
    .btn-primary {
        @apply inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-offset-gray-900;
    }
    .card {
        @apply rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800;
    }
    .nav-link {
        @apply text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white;
    }
}[]

GSPs then reference them by the short name:

<a href="https://grails.apache.org/guides/grails-tailwindcss/8/guide/index.html" class="btn-primary">Read the guide</a>
<section class="card">...</section>
<a href="https://grails.apache.org/docs/" class="nav-link">Docs</a>

Three things to notice:

  • @apply is still supported in v4, which keeps the component layer readable even for long utility chains.

  • Tailwind 4 also introduces @utility for custom utilities. For this guide we stay with @layer components because the goal is a tiny named component surface that reads naturally in GSPs.

  • Component classes do not defeat purging. The scanner still decides what utilities to emit from the classes and variants it can see in your source files.

Dynamic class names in Groovy code: if a controller or tag library composes a class string at runtime, Tailwind’s static analyser cannot see the result. In v4 the explicit fix is @source inline(…​) in input.css, for example @source inline("alert-info alert-warn alert-error");.

Files touched in this chapter:

  • src/main/css/input.css

  • grails-app/views/layouts/main.gsp

  • grails-app/views/index.gsp

9 Do you need help with Grails?

Help with Apache Grails

Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.

Files touched in this chapter:

  • None