craftpulse / craft-tailwind
Tailwind CSS class merging and named-slot class builder for Craft CMS 5
Package info
github.com/craftpulse/craft-tailwind
Type:craft-plugin
pkg:composer/craftpulse/craft-tailwind
Requires
- php: ^8.2
- craftcms/cms: ^5.0.0
- michtio/tailwind-merge-v3: ^1.2
- michtio/tailwind-merge-v4: ^0.3
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
- pestphp/pest: ^3.0
- phpstan/phpstan: ^1.0
This package is auto-updated.
Last update: 2026-05-14 12:49:07 UTC
README
Server-side Tailwind CSS class merging for Craft CMS 5 templates. Resolves conflicting utility classes, organizes styles into named slots, and injects CSS custom properties -- all without client-side JavaScript.
What it solves
Component overrides without conflicts
A button component defines default colors. A page template wants to override just the color, but concatenating classes produces bg-brand-accent bg-red-500 -- both classes render, the cascade picks a winner unpredictably, and the source is impossible to reason about.
With craft-tailwind, the merge engine understands which utilities conflict and keeps only the last one per CSS property:
{# _components/button.twig #} {% set classes = craft.tailwind.classes({ layout: 'inline-flex items-center gap-2', color: 'bg-brand-accent text-white', radius: 'rounded-sm', spacing: 'py-2 px-4', }) %} {# Override just the color slot -- layout, radius, spacing stay intact #} {% set classes = classes.override({ color: 'bg-red-600 text-white' }) %} <button class="{{ classes }}">Submit</button> {# Output: inline-flex items-center gap-2 bg-red-600 text-white rounded-sm py-2 px-4 #}
Dynamic colors without safelisting
A CMS field lets editors pick a brand color. Tailwind tree-shakes unused classes at build time, so writing bg-{{ color }} produces nothing -- the class never made it into the build.
CSS custom properties sidestep this entirely. Define your palette once in plugin settings, reference the variables in your templates with arbitrary value syntax, and swap values at runtime:
{# Inject CSS variables into the page head -- no |raw needed #} {{ craft.tailwind.include() }} {# Use arbitrary value syntax -- these classes ARE in the build #} <div class="bg-[var(--color-brand-primary)] text-[var(--color-brand-on-primary)]"> Editor-chosen colors, no safelisting required. </div>
Form field modifiers
A form input has default styling. Error state needs a red border. Disabled state needs muted colors. Without merge, layering modifier classes means manually tracking which base classes to remove:
{% set base = 'border border-gray-300 rounded-md px-3 py-2 text-sm' %}
{% set error = 'border-red-500 ring-1 ring-red-500' %}
{% set disabled = 'bg-gray-100 text-gray-400 cursor-not-allowed' %}
{# Merge resolves border-gray-300 vs border-red-500 automatically #}
<input class="{{ craft.tailwind.merge(base, hasErrors ? error : '', isDisabled ? disabled : '') }}" />
Installation
You can install Tailwind via the Plugin Store in the Craft control panel, or from the command line.
With DDEV:
ddev composer require craftpulse/craft-tailwind ddev craft plugin/install tailwind
Without DDEV:
composer require craftpulse/craft-tailwind php craft plugin/install tailwind
Tailwind works on Craft 5.x and requires PHP 8.2 or later.
Configuration
Settings can be managed through the control panel (Settings > Plugins > Tailwind) or via a config file. File-based configuration takes precedence over CP settings.
Config file
Create config/tailwind.php:
<?php return [ // 'auto' | '3' | '4' 'tailwindVersion' => 'auto', // Directory containing tailwind.config.* (v3 detection) 'buildchainPath' => null, // Directory containing CSS entry file (v4 detection) 'cssPath' => null, // CSS custom properties injected as :root variables 'cssVariables' => [ '--color-brand-primary' => '#3490dc', '--color-brand-on-primary' => '#ffffff', ], // LRU cache size for merge results (0-10000) 'cacheSize' => 500, // Tailwind class prefix, bare form (no trailing hyphen). // v3 emits `tw-px-4`; v4 emits `tw:px-4`. null = no prefix. 'prefix' => null, // Resolve `@tailwindcss/typography` conflicts (prose-sm vs prose-lg etc.) 'typography' => false, // Custom prose-{suffix} additions beyond the defaults the typography // plugin ships. Add suffixes you've registered yourself. 'typographyExtraSizes' => [], 'typographyExtraColors' => [], // Auto-inject CSS variables into every site page's <head> 'autoInject' => false, // Attributes applied to the auto-injected <style> tag // (e.g. ['nonce' => '...', 'media' => 'screen']) 'autoInjectAttributes' => [], ];
When a setting is defined in both the config file and the CP, the config file value wins. The CP settings page shows a warning on each overridden field.
About the cacheSize setting
The merge engine is called every time you use craft.tailwind.merge() or build a ClassList. To avoid re-parsing the same class strings over and over within a single request, results are kept in an in-memory LRU cache (Least Recently Used). When the cache fills up, the entry that hasn't been used for the longest gets evicted to make room — so "hot" class strings (e.g. your main button's classes) stay resident.
The cache is per-request: it lives only for the duration of one PHP process. You don't need to think about invalidation. Set cacheSize to 0 to disable caching entirely (useful when debugging a merge).
Tip — HMR and the LRU don't interact. Because the cache is per-request, it starts empty on every page load. Editing
tailwind.config.js, your CSS entry, the plugin settings, or anything Vite picks up will be reflected on the very next request — there's no stale cache to clear during development. The one place merges can survive across requests is inside Craft's{% cache %}blocks; that's normal template caching, not the plugin's LRU, and the usual{% cache %}invalidation rules apply.
Multi-environment configuration
The config file supports Craft's standard multi-environment pattern:
<?php return [ '*' => [ 'tailwindVersion' => 'auto', 'autoInject' => false, ], 'dev' => [ 'autoInject' => true, ], ];
Usage
Class merging
craft.tailwind.merge() resolves conflicting Tailwind utilities. The last class per CSS property group wins:
{{ craft.tailwind.merge('px-4 bg-red-500', 'bg-blue-500 mt-4') }}
{# Result: px-4 bg-blue-500 mt-4 #}
Accepts any number of arguments. Each argument is a space-separated class string.
Named-slot ClassList
When a component has many style concerns, a flat class string becomes hard to override selectively. The ClassList object splits classes into named slots, each representing a single responsibility:
{% set btn = craft.tailwind.classes({
layout: 'inline-flex items-center group w-fit',
color: 'bg-brand-accent text-brand-on-accent',
font: 'font-heading font-bold text-base',
hover: 'hover:bg-brand-accent-hover',
radius: 'rounded-sm rounded-br-2xl',
spacing: 'py-2 px-4 gap-2',
focus: 'focus:ring-2 focus:ring-brand-focus focus:ring-offset-1 focus:outline-none',
}) %}
{# Renders all slots merged into a single class string #}
<button class="{{ btn }}">Click me</button>
The ClassList object is immutable. Every mutation returns a new instance:
{# Replace an entire slot #} {% set danger = btn.override({ color: 'bg-red-600 text-white' }) %} {# Append classes to an existing slot #} {% set bordered = btn.extend({ border: 'border-2 border-brand-muted' }) %} {# Remove slots entirely #} {% set minimal = btn.without('hover', 'focus') %} {# Read a single slot #} {{ btn.get('color') }} {# Result: bg-brand-accent text-brand-on-accent #} {# Merge additional utilities on top of all slots #} {{ btn.merge('mt-8 shadow-lg') }}
Works naturally with Craft's {% tag %} helper:
{% tag 'a' with {
class: craft.tailwind.classes({
layout: 'inline-flex items-center',
color: 'bg-brand-accent text-brand-on-accent',
}),
href: entry.url,
} %}
{{ entry.title }}
{% endtag %}
CSS variables
Define CSS custom properties in plugin settings (or config/tailwind.php), then render them as a <style> tag in your layout. The include() method returns a Twig\Markup object, so you don't need the |raw filter:
{# In your layout's <head> #} {{ craft.tailwind.include() }} {# Renders: <style>:root { --color-brand-primary: #3490dc; ... }</style> #}
CSP and subresource integrity
Pass attributes to the <style> tag — useful for Content Security Policy nonces or other custom attributes:
{{ craft.tailwind.include({ nonce: cspNonce }) }}
{# Renders: <style nonce="abc123">:root { ... }</style> #}
{{ craft.tailwind.include({ media: 'screen' }) }}
The plugin doesn't assume a nonce source — wire it up to your CSP module's per-request nonce however you expose it (a variable, a service, a Twig global).
Auto-inject
If you don't want to think about where the tag goes, enable the Auto-Inject setting (CP or config/tailwind.php). The plugin will register the style block via Craft's View::registerCss() on every site request automatically:
// config/tailwind.php return [ 'autoInject' => true, 'autoInjectAttributes' => [ 'nonce' => 'static-nonce-or-leave-out', ], ];
Auto-inject is skipped on console requests and CP requests. If you use a dynamic per-request CSP nonce, keep auto-inject disabled and call {{ craft.tailwind.include({ nonce: cspNonce }) }} in your layout so the nonce can be resolved at render time.
Inspecting variables
Use craft.tailwind.cssVariables when you need to look up or iterate variables instead of rendering them:
{% if craft.tailwind.cssVariables.has('color-brand-primary') %}
Primary: {{ craft.tailwind.cssVariables.get('color-brand-primary') }}
{% endif %}
{# Raw CSS without a <style> wrapper #}
{{ craft.tailwind.cssVariables.asCss() }}
Sanitization and naming
The CssVariables object sanitizes values to prevent CSS injection. Values containing characters outside the safe set (letters, digits, hyphens, underscores, dots, hashes, commas, parentheses, percent signs, slashes, spaces, and quotes) are silently dropped. In devMode, dropped values are logged as warnings.
Variable names are auto-prefixed with -- if missing, so both color-brand and --color-brand resolve to the same property.
Version detection
The plugin auto-detects whether your project uses Tailwind v3 or v4 and selects the correct merge engine. Detection follows this priority:
- CSS signals -- scans the CSS path for
@import "tailwindcss"or@themedirectives (definitive v4 indicators) - Config files -- looks for
tailwind.config.{js,ts,cjs,mjs}in the buildchain path (v3 indicator) - Fallback -- defaults to v4 with a devMode warning
Set buildchainPath and cssPath in settings to point detection at the right directories. When unset, both default to the project root.
{{ craft.tailwind.version }}
{# Result: '3' or '4' #}
To skip detection entirely, set tailwindVersion to '3' or '4' explicitly.
Debug toolbar panel
When Craft's debug toolbar is enabled (devMode + a user with debug access), a Tailwind panel appears alongside the others. It records every merge operation during the current request and shows:
- Total calls / unique inputs — how many times
merge()ran, and how many distinct input strings were seen - Cache stats — hit count, hit rate, and current LRU entry count
- Per-merge detail — the input, the resolved output, whether a conflict was actually resolved (vs passthrough), the call count, and the template that ran the merge
Use it to answer questions like "why is bg-red-500 not appearing on the page?" (find the merge input where it was overridden) or "is my cache actually helping?" (check the hit rate).
The panel has no overhead outside of debug-enabled requests — data collection only runs when the debug module is loaded.
Typography plugin support
The @tailwindcss/typography plugin registers prose-{size} and prose-{theme} classes that neither underlying merge engine knows about by default — so out of the box prose prose-sm prose-lg passes through unchanged. The plugin's typography setting opts you into a curated conflict-group config that covers the suffixes shipped by @tailwindcss/typography 0.5.x:
- Sizes:
sm,base,lg,xl,2xl - Colors / themes:
gray,slate,zinc,neutral,stone,invert
Enable it in plugin settings or in config/tailwind.php:
return [ 'typography' => true, ];
Now size and theme conflicts resolve last-wins, while size and color stay orthogonal:
{# Size last-wins #} {{ craft.tailwind.merge('prose prose-sm', 'prose-lg') }} {# Result: prose prose-lg #} {# Theme last-wins #} {{ craft.tailwind.merge('prose prose-slate', 'prose-invert') }} {# Result: prose prose-invert #} {# Size + color stay together — different concerns #} {{ craft.tailwind.merge('prose prose-lg', 'prose-invert') }} {# Result: prose prose-lg prose-invert #}
A typical rich-text area with editor-controlled size:
{% set proseSize = entry.proseSize.value ?? 'prose-base' %}
<article class="{{ craft.tailwind.merge('prose prose-slate max-w-none', proseSize) }}">
{{ entry.body|raw }}
</article>
Custom typography themes
If you've registered your own prose-* themes — for example a brand variant via an @utility prose-mybrand { ... } block on v4, or a theme.extend.typography.mybrand entry on v3 — add the suffixes to the extras lists so the merger treats them as conflict-group members:
return [ 'typography' => true, 'typographyExtraSizes' => ['huge'], 'typographyExtraColors' => ['mybrand', 'marketing'], ];
With the extras above, merge('prose prose-slate', 'prose-mybrand') resolves to prose prose-mybrand. Suffixes are stored without the prose- prefix.
You can also manage the toggle and extras through the CP settings page (under Typography). Defaults are always included — extras only need entries for suffixes you've registered yourself.
Why opt-in?
Resolving prose-* conflicts by default would surprise users who use the typography plugin alongside their own prose-* naming conventions, or who don't use the typography plugin at all and would rather see their prose-* classes pass through unchanged. The toggle keeps the default behavior predictable and lets typography users enable resolution explicitly.
API reference
Template variables (craft.tailwind)
| Property / Method | Returns | Description |
|---|---|---|
.merge('a', 'b', ...) |
string |
Merge multiple class strings |
.classes({ slot: 'classes' }) |
ClassList |
Named-slot class builder |
.version |
string |
Detected Tailwind version ('3' or '4') |
.cssVariables |
CssVariables |
CSS custom properties container |
.include(attributes = {}) |
Twig\Markup |
Ready-to-render <style> tag — no |raw required |
ClassList methods
| Method | Returns | Description |
|---|---|---|
__toString() |
string |
All slots merged into a single class string |
.get(slot) |
?string |
Value of a single slot |
.override({ slot: '...' }) |
ClassList |
New instance with replaced slots |
.extend({ slot: '...' }) |
ClassList |
New instance with appended slot values |
.without('slot', ...) |
ClassList |
New instance without named slots |
.merge('additional') |
string |
All slots + additional merged to a string |
.toArray() |
array |
Named slots as an associative array |
CssVariables methods
Use these when you need introspection; for rendering prefer craft.tailwind.include() on the variable.
| Method | Returns | Description |
|---|---|---|
__toString() |
string |
:root { ... } CSS block |
.asCss() |
string |
:root { ... } CSS block (no <style> wrapper) |
.get(name) |
?string |
Value of a single variable |
.has(name) |
bool |
Whether a variable exists |
.all() |
array |
All variables as key-value pairs |
.isEmpty() |
bool |
Whether the collection is empty |
Credits
This plugin wraps two PHP merge libraries:
- gehrisandro/tailwind-merge-php (Tailwind v3)
- tales-from-a-dev/tailwind-merge-php (Tailwind v4)
Brought to you by CraftPulse.
