Skip to content

Tailwind

@voltage/tailwind compiles Tailwind CSS v4 at application startup using Tailwind’s native Node API and injects the result into rendered HTML automatically. HTMX partial updates are detected and skipped — the <style> tag carries hx-preserve="true" so HTMX never replaces it.

Terminal window
yarn add @voltage/tailwind @voltage/async-context @voltage/event-manager @voltage/jsx

Call forRoot() once in your root module with a path to your theme CSS file:

import { TailwindModule } from '@voltage/tailwind';
@Module({
imports: [
TailwindModule.forRoot({
base: '/app/assets/theme.css',
}),
],
})
export class AppModule {}

Tailwind compiles on ApplicationBootstrapEvent and injects the result into the <head> of every full-page response. No additional wiring is needed.

The base path points to a standard Tailwind v4 CSS entry file. A minimal setup imports Tailwind, configures content scanning, and sets design tokens:

@import 'tailwindcss';
@plugin "@voltage/htmx/tailwind";
@source "/app/dist/**/*.js";
@theme {
--color-primary: #1581e4;
--color-danger: #ec1f00;
}

@source tells Tailwind where to scan for class names. Point it at your compiled JS output — the scanner extracts class names from the rendered JSX strings.

If you are using @voltage/ui, import its theme to get the full design token set:

@import 'tailwindcss';
@import '@voltage/ui/theme';
@plugin "@voltage/htmx/tailwind";
@source "/app/dist/**/*.js";
@theme {
/* override tokens as needed */
--color-primary: #1581e4;
}

Importing @plugin "@voltage/htmx/tailwind" adds four variants that map to HTMX’s lifecycle classes:

VariantActive when
htmx-request:Request is in flight (htmx-request class)
htmx-settling:Response has swapped, settling in progress
htmx-swapping:Swap is in progress
htmx-added:Element was just added to the DOM

A common use is dimming a button or showing a spinner while a request is in flight:

<button
hx-post={this.url.generate(CartController, 'add')}
hx-swap="none"
class="htmx-request:opacity-50 htmx-request:cursor-wait"
>
Add to cart
</button>

Or on an hx-indicator element:

<form {...action(this.url.generate(AuthController, 'login'))} hx-indicator="#spinner">
{/* fields */}
<button type="submit">
Log in
<span id="spinner" class="opacity-0 htmx-request:opacity-100"></span>
</button>
</form>

Both variants match on the element itself and on any descendant — htmx-request:opacity-50 works whether the class is on the element or on a parent.

TailwindCollectEvent fires before each compilation pass. Listen to it in any module to contribute CSS fragments — this is how @voltage/ui injects its component styles without requiring manual imports in the theme file:

import { OnEvent } from '@voltage/event-manager';
import { TailwindCollectEvent } from '@voltage/tailwind';
@Injectable()
export class BadgeStyles {
@OnEvent(TailwindCollectEvent)
onCollect(event: TailwindCollectEvent) {
event.addFragment(`
@layer components {
.badge { @apply inline-flex items-center rounded px-2 py-0.5 text-sm; }
}
`);
}
}

addFile(path) is available when the CSS lives in a separate file:

@OnEvent(TailwindCollectEvent)
onCollect(event: TailwindCollectEvent) {
event.addFile(join(__dirname, 'badge.css'));
}

Fragments are concatenated with the base theme file before compilation, so all Tailwind directives (@apply, @layer, @theme) work as expected.

Set watch: true to recompile whenever the theme file or any of its imports change. The recompiled CSS is served on the next request — no restart required.

TailwindModule.forRoot({
base: '/app/assets/theme.css',
watch: process.env.NODE_ENV !== 'production',
})
interface TailwindConfiguration {
/** Absolute path to the Tailwind CSS entry file. */
base: string;
/** Watch the theme file for changes and recompile automatically. Defaults to false. */
watch?: boolean;
}