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/asset-manager @voltage/event-manager @voltage/jsx
import { TailwindModule } from '@voltage/tailwind';
@Module({
imports: [
TailwindModule.register({
base: '/app/assets/theme.css',
}),
],
})
export class AppModule {}
TailwindModule.registerAsync({
inject: [AppConfiguration],
useFactory: (config: AppConfiguration) => ({
base: config.tailwind.base,
watch: config.tailwind.watch,
}),
})

Tailwind compiles on ApplicationBootstrapEvent and injects the result into the <head> of every full-page response via <TailwindStyle />. 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',
})

Pass a name as the second argument to register() or registerAsync() to run more than one Tailwind instance in the same application. Each instance compiles independently and is served under its own asset URL:

TailwindModule.register({ base: '/app/assets/theme.css' }, 'main')
TailwindModule.register({ base: '/app/assets/email.css' }, 'email')

Render a specific instance by name with <TailwindStyle />:

<TailwindStyle name="main" />

When no name is given, "default" is used. The @voltage/mailer package uses the tailwindName configuration option to select which instance to pull compiled CSS from during email inlining.

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;
}