Skip to content

Tailwind CSS

Templates can use Tailwind CSS utility classes directly in their .tpl markup. Tailwind is opt-in per template and is produced by a real build — the same engine runs in the live tpltest preview and in the production deploy, so what you see while developing matches what ships. There is no separate build or watch process to run.

Currently Tailwind 3.4.x is supported.

Quick start

Three steps get a template using Tailwind. Only the first two are required.

1. Opt in — add tailwind to plugins in config.json:

json
{
    "plugins": {
        "jquery": "3.4",
        "tailwind": "3.4"
    }
}

2. Add tailwind.config.js at the template root (theme + any customization):

javascript
// tailwind.config.js
module.exports = {
    theme: {
        extend: {
            colors: { brand: '#0d6efd' }
        }
    }
}

3. (Optional) Add css/tailwind.css for component classes built with @apply:

css
/* css/tailwind.css — custom CSS only; the build adds the @tailwind directives */
@layer components {
    .btn { @apply inline-flex items-center rounded-full px-6 py-3 font-semibold; }
}

Now write utilities in any .tpl:

twig
<a href="{{ product.url }}" class="btn bg-brand text-white hover:brightness-95">
    {{ product.name }}
</a>

Reload the tpltest preview and the styles appear. That's it — the rest of this page explains the details.

Enabling Tailwind

The tailwind line in config.json plugins is the opt-in. When present, the build scans your templates for utility classes and folds the generated Tailwind CSS into the template's stylesheet. When absent, nothing about Tailwind runs and your CSS is unchanged.

See Configuration for the full config.json reference and Getting Started for how plugins are injected.

Theme configuration

tailwind.config.js (or tailwind.config.cjs) at the template root is a standard Tailwind v3 config:

javascript
// tailwind.config.js
module.exports = {
    // content is optional — see below
    theme: {
        extend: {
            colors: { brand: '#0d6efd' }
        }
    }
}
  • theme — custom colors, spacing, fonts, breakpoints, etc. These become utilities (e.g. the config above enables text-brand, bg-brand).
  • content — which files are scanned for class names. It defaults to **/*.tpl and js/**/*.js, so templates and layout JavaScript are covered without configuring anything. Only set content if you keep markup or class strings somewhere else. Paths are resolved relative to the template repository, wherever the build runs.

INFO

The config is plain Node.js and runs at build time, so the full Tailwind config API is available (theme.extend, corePlugins, safelist). npm plugins (e.g. @tailwindcss/forms) are not installed for template builds — keep the config free of require() for first-party plugins.

Using utilities in templates

Write Tailwind utility classes on any element in your .tpl files. Twig syntax does not interfere with class detection:

twig
{# zbozi.tpl - product detail #}
<div class="flex flex-col gap-4 md:flex-row">
    <i:img src="product.image" format="400x400" class="rounded-lg shadow" />
    <div class="flex-1">
        <h1 class="text-2xl font-bold text-brand">{{ product.name }}</h1>
        <p class="mt-2 text-gray-600">{{ product.shortDescription }}</p>
    </div>
</div>

You can mix utilities with your existing SCSS classes freely.

All standard Tailwind v3 features work: responsive prefixes (md:, lg:), state variants (hover:, focus-visible:, group-hover:), dark:, and arbitrary values (top-[37px], bg-[#abc], w-[280px]). One caveat — an arbitrary value containing is a dynamic class and will be rejected; compute the whole value first.

Right-to-left (RTL) layouts

Layouts are deployed in both LTR and RTL directions, but Tailwind utilities are not auto-flipped — the generated CSS is identical for both builds. Physical utilities like ml-4, pl-2, or left-0 stay left-handed in an RTL deploy.

For direction-aware spacing and positioning, use Tailwind's logical utilities, which flip automatically with the document direction:

PhysicalLogical (direction-aware)
ml-* / mr-*ms-* / me-*
pl-* / pr-*ps-* / pe-*
left-* / right-*start-* / end-*
rounded-l-* / rounded-r-*rounded-s-* / rounded-e-*

For cases logical utilities don't cover, the rtl: and ltr: variants apply a class only in that direction (e.g. rtl:rotate-180).

Component classes with @apply

For repeated patterns (buttons, badges, cards) you often want a single class backed by @apply. SCSS cannot process @apply, so these belong in the Tailwind entry file css/tailwind.css, not in base.scss.

The entry file holds custom CSS only — the build supplies the @tailwind base/components/utilities directives itself (so do not add them here):

css
/* css/tailwind.css */
@layer components {
    .btn {
        @apply inline-flex items-center justify-center rounded-full px-6 py-3
               font-semibold transition active:scale-95;
    }
    .btn-buy { @apply bg-brand text-white hover:brightness-95; }
    .badge   { @apply inline-flex px-2 py-0.5 rounded-lg text-xs uppercase; }
}
  • @apply resolves against your tailwind.config.js theme (so bg-brand uses your custom color).
  • Component classes are tree-shaken like utilities: .btn-buy is only emitted if btn-buy appears in a scanned file.
  • Put rules that must override utilities (e.g. a media-query tweak of a generated class) in @layer utilities or as plain CSS — those land after the utilities. Put component definitions in @layer components.
  • The entry must be self-contained: @import is not resolved and the build fails if you use one. It is consumed only by the Tailwind build; base.scss stays your separate SCSS entry.

Cascade and layer order

The build follows Tailwind's canonical order so utilities win. The effective order in the final stylesheet is:

1. Tailwind base (Preflight reset) + components (incl. your @layer components / @apply)
2. your SCSS (base.scss and its @imports)
3. Tailwind utilities (incl. your @layer utilities)

What this means in practice:

  • A utility beats a same-specificity rule in your SCSS — class="hidden" hides the element even if SCSS sets display on it. This matches utility-first expectations.
  • Your SCSS can override a component class (components come before your SCSS).
  • Preflight (base) comes first; your class-based SCSS always beats Preflight's element resets.

Only the utilities you actually use are generated (Tailwind's JIT engine), so the output stays small.

Preflight reset

The base layer includes Tailwind's Preflight, which resets margins, removes list styles, unstyles headings, and more. If your template relies on a different reset (e.g. Foundation's base styles), Preflight may change existing visuals. Disable it while keeping components and utilities:

javascript
// tailwind.config.js
module.exports = {
    corePlugins: { preflight: false }
}

Dynamic class names

This is the most common pitfall when moving a template to the build. Tailwind only generates classes it finds as complete literal strings in scanned files. A class assembled at runtime is never seen:

twig
{# NOT generated — the full class never appears literally #}
<div class="bg-gradient-radial-{{ category.color }}"></div>
<div class="bg-{{ variant }}"></div>
<div class="{{ 'bg-' ~ variant }}"></div>   {# string concatenation is the same trap #}

Because the build scans static source — not a live, rendered DOM — any interpolated or concatenated class silently disappears (e.g. a missing background).

The fix is to use full, literal class names. When the value is dynamic, map the key to a complete class so the full strings appear in the source:

twig
{# Good — full class names live in the map; the build extracts them #}
{% set _gradients = {
    'cyan-green':  'bg-gradient-radial-cyan-green',
    'purple-pink': 'bg-gradient-radial-purple-pink'
} %}
<div class="{{ _gradients[category.color]|default('bg-gradient-radial-cyan-green') }}"></div>

For a fixed set of variants, put the complete classes in the array, not a prefix:

twig
{% set _variants = ['bg-gradient-radial-purple-pink', 'bg-gradient-radial-cyan-green'] %}
<div class="{{ _variants[item.id % 2] }}"></div>

WARNING

This is enforced. The template validator fails a Tailwind-enabled template when an interpolation is glued directly onto a class name inside a class attribute — a class character immediately touching the opening or closing interpolation braces (such as a bg- prefix joined to an interpolation, or inset-0 with no space before one). Keep interpolation as a whole, space-separated token, and back it with literal class names. (The check runs in CI via the template validator, so a violation blocks the pipeline.)

INFO

As a last resort, for classes that genuinely never appear in source (e.g. injected by third-party scripts), add them to safelist in tailwind.config.js. Prefer literal classes — safelist silently rots when the set of values changes.

Sharing tokens with SCSS

When both Tailwind utilities and hand-written SCSS need the same brand colors, you have two patterns. Pick based on whether you need Tailwind's opacity modifiers (bg-brand/50).

Pattern A — mirrored values (simple, what most templates do). Hex values live in tailwind.config.js; the SCSS side re-declares them as CSS variables (commonly in a css/_tokens.scss partial). The two are kept in sync by hand:

javascript
// tailwind.config.js
module.exports = { theme: { extend: { colors: { brand: '#0d6efd' } } } }
scss
// css/_tokens.scss — mirror of the Tailwind theme (keep in sync)
:root { --brand: #0d6efd; }
.legacy-banner { background: var(--brand); }

Opacity modifiers (bg-brand/50), @apply bg-brand, and theme() all work normally because Tailwind sees a real hex color. The cost is two places to edit.

Pattern B — single source via CSS variables (zero duplication). Define the variables once and point the Tailwind theme at them:

css
/* css/tailwind.css */
@layer base { :root { --brand: 13 110 253; } }   /* space-separated RGB channels */
javascript
// tailwind.config.js
module.exports = {
    theme: { extend: { colors: { brand: 'rgb(var(--brand) / <alpha-value>)' } } }
}

The <alpha-value> channel form is required for opacity modifiers to keep working — a plain var(--brand) full color breaks bg-brand/50. SCSS then uses rgb(var(--brand)).

INFO

Pattern A is the pragmatic default and matches existing templates. Reach for Pattern B only when the duplication actually bites; it trades a little setup for one source of truth.

How it works

The same build runs in both environments:

EnvironmentWhat happens
tpltest (development)The template's CSS is recompiled on each request. Tailwind scans your .tpl and js/ files live, so a class you just added appears after a reload — no manual rebuild step.
Production (deploy)Tailwind runs once during the deploy and the generated CSS is bundled into the minified, deployed template stylesheet.

Because both paths use the same tailwind.config.js, the same css/tailwind.css, and the same engine, development and production stay in sync.

Troubleshooting

A utility class has no effect.

  • Is the full class a literal in a scanned file? Constructed names (bg-) are not generated — see Dynamic class names.
  • If the class is only referenced from JavaScript outside js/, add that path to content.
  • In tpltest, reload the page — the CSS recompiles per request.

A class works in tpltest but not in production (or vice-versa). Both use the same config and engine, so this is almost always a dynamic/constructed class that the local DOM happened to contain. Make it a literal.

My SCSS no longer overrides a utility. That is intentional — utilities now win (see Cascade and layer order). Move the rule into a component class, or raise its specificity.

Preflight changed my base styles. Disable it with corePlugins.preflight = false (see Preflight reset).

The lint fails with "Dynamic Tailwind class names". A class is glued to an interpolation inside a class attribute. Use literal class names and keep interpolation space-separated.

Next steps

  • Configuration — full config.json reference, including plugins
  • Getting Started — minimal template and the SCSS build order
  • HTML Elements — platform tags such as i:img used alongside utilities
  • Performance — CSS minification, critical CSS, and asset handling