Tailwind v4 and Alpine.js: Combining the CSS-First Workflow
AI generated
x-data
Alpine
Alpine.js / Styling
Tailwind v4 and Alpine.js
combining the CSS-first workflow

Tailwind v4 drops the central tailwind.config.js in favor of the @theme directive right inside CSS. For plain utility classes little changes in an Alpine template, but wherever an Alpine component used to read configuration values from JavaScript, breakpoints or color values for dynamic calculations, the source of truth shifts to CSS custom properties. Understanding that shift saves a lot of debugging when moving a v3 setup to v4.

10 min read Tailwind v4 @theme CSS variables

1. Leaving tailwind.config.js behind: what @theme actually changes

Through Tailwind v3, the entire design configuration, colors, spacing, breakpoints, font sizes, lived in a JavaScript file evaluated at build time. Anyone who needed those values programmatically, say to know the current breakpoint threshold inside an Alpine component for a calculation, either duplicated the values by hand or ran resolveConfig() in a separate build step to expose them as JSON.

Tailwind v4 flips that principle: configuration now lives as an @theme block right inside the entry CSS file and compiles down to real CSS custom properties. What used to be theme.colors.teal.500 in the old config becomes --color-teal-500 in the compiled CSS. That means those values are readable at runtime in the browser via getComputedStyle(), with no separate JSON export or build step, a far more direct access path for Alpine components than before.


/* app/design/frontend/Mironsoft/default/web/tailwind/tailwind-source.css */
@import "tailwindcss";

@theme {
  --color-brand-accent: oklch(0.72 0.19 42);
  --breakpoint-shop-nav: 64rem;
  --spacing-card-gap: 1.25rem;
}

2. CSS variables instead of theme(): the new access path for Alpine

In Tailwind v3, custom CSS occasionally used the theme('colors.teal.500') function to reach configuration values outside utility classes. That function still exists in v4 for backward compatibility, but it is no longer the recommended path. Instead you reach directly into the generated CSS variable, for example var(--color-brand-accent), both in your own CSS and, this is the key difference, directly from Alpine.js through the DOM API.

For an Alpine component this means: instead of duplicating a color or breakpoint value in a separate JS constant, you read it at runtime straight from the computed style of the root element. That removes an entire class of drift bugs where the JS copy of a design token stops matching the actual CSS value after a redesign, simply because there is no second source left that could go stale.


// Alpine component reads a design token straight from CSS
Alpine.data('accentAwareBanner', () => ({
  accentColor: '',
  init() {
    const styles = getComputedStyle(document.documentElement);
    this.accentColor = styles.getPropertyValue('--color-brand-accent').trim();
  },
}));

3. Practical example: a dynamic accent color via CSS variables and x-bind:style

A concrete use case: a campaign banner should switch its accent color depending on the active product category, where the available colors are maintained as Tailwind v4 theme tokens. Instead of managing a list of fixed Tailwind classes such as bg-teal-500, bg-orange-500 in JavaScript and assembling them dynamically, which collides with v4's content detection as described in the next section, you bind the color directly as a CSS variable through x-bind:style.

The advantage of this approach is that the utility class in the template stays static and therefore visible to Tailwind's scanner, while only the value of the CSS variable changes at runtime. That combines the robustness of Tailwind's static analysis with the flexibility of real runtime state, without giving up either benefit.


<div
  x-data="{ accent: '--color-teal-500' }"
  x-bind:style="{ '--banner-accent': `var(${accent})` }"
  class="rounded-lg p-6 border-l-4"
  style="border-color: var(--banner-accent, var(--color-brand-accent))"
>
  <button x-on:click="accent = '--color-orange-500'" class="text-sm underline">
    Switch category
  </button>
</div>

4. Automatic content detection and dynamically assembled class names

Tailwind v4 automatically scans source files for class names and, unlike v3, no longer needs an explicit content list in the config. That works reliably as long as class names appear as complete, unchanged strings in the source. For Alpine components that build class names dynamically from substrings, for example `bg-${color}-500`, that is a problem, because the scanner only sees the template with the template literal and cannot resolve the runtime combination.

The reliable fix is to keep every actually needed class combination as a complete, static string somewhere in the source, for example in a comment line or a lookup table in the same template, so the scanner picks it up. An even more robust approach, and the more natural one in v4's CSS-first world, is to skip fixed class names altogether as shown in the previous section and work with CSS variables instead, which can change at runtime independent of the scanner.

5. Migration effort: what to watch for when moving v3 Alpine setups

When migrating an existing Hyvä or Alpine project from Tailwind v3 to v4, it pays to first inventory every place where JavaScript, and therefore Alpine components, read values from tailwind.config.js. That most often affects breakpoint values used in window.matchMedia() calls inside Alpine components, or color values needed dynamically for canvas or SVG drawing.

Every one of those spots needs to switch to reading via getComputedStyle() and CSS custom properties, which in practice is usually worth a small, reusable helper function in the Alpine codebase. Alongside that, it is worth reviewing every dynamically assembled class string in the project, since those are the ones most likely to silently break under v4's automatic content detection as described above, and only show up as missing styling in production.

6. @apply and component classes in the CSS-first workflow

For recurring combinations of utility classes that show up across several Alpine components, @apply remains usable in Tailwind v4, though in the spirit of CSS-first it is best kept inside an @layer components block right next to the @theme block, rather than scattered as utility chains in HTML. That keeps Alpine templates readable, especially when a component like an accordion or a dropdown has several states with their own class combinations.

Important: @apply resolves at build time and therefore knows nothing about Alpine's runtime state. For classes that need to change depending on x-data state, x-bind:class is still required, @apply component classes only make sense for the static part of the markup that always looks the same regardless of Alpine state.


@layer components {
  .accordion-panel {
    @apply rounded-lg border border-gray-200 px-4 py-3 transition-colors;
  }
}

7. Specifics in the Hyvä/Magento context

In a Hyvä theme, the @theme configuration is typically maintained in a project-specific CSS source file under web/tailwind/, which imports the values inherited from the parent theme and selectively overrides them. Since Hyvä enforces a strict Content Security Policy, v4 changes nothing about how inline scripts are handled, every Alpine component that reads CSS variables remains a perfectly normal script snippet, registered the usual way through the Hyvä CSP component's registerInlineScript().

A practical benefit in the Magento context: since shop operators often need different accent colors per store view or website, those can now be overridden cleanly as CSS custom properties per theme variant, without touching a single line of Alpine JavaScript, as long as components consistently work through var() instead of fixed class names.

8. Build performance: the Oxide engine and its effect on the dev workflow

Tailwind v4 uses a new build engine written in Rust, internally known as Oxide, which delivers noticeably shorter build times than the v3 engine, especially for incremental rebuilds during development. For a Hyvä project with many Alpine components and frequent style tweaks, that means noticeably shorter wait times in watch mode, which speeds up the feedback loop when fine-tuning dynamic classes and CSS variables.

A side effect of the faster, more direct content detection is that errors from dynamically assembled class names surface faster, since the build no longer masks them behind long wait times. Anyone consistently applying the switch to CSS variables described in the fourth section benefits twice: faster builds and fewer cases where the scanner could miss anything in the first place.

9. A checklist for a clean migration

Before migrating, a short, ordered approach pays off: first list every JS access to the old tailwind.config.js, then convert each spot individually to CSS custom properties, next search for every dynamically assembled class string in Alpine templates and replace it with a CSS variable binding, and finally run the build in watch mode and visually re-check every affected component.

Following that order avoids the most common migration problem: part of the classes suddenly missing in the production build because the scanner no longer recognized a dynamically built combination, an error that often stays invisible in local development with unpurged CSS and only shows up after deployment.

Aspect Tailwind v3 Tailwind v4 Effect on Alpine
Config location tailwind.config.js @theme block in CSS No JS import needed, values readable via CSS
Access from JS resolveConfig() / manual copy getComputedStyle() on CSS variable One single source of truth, no duplicates
Dynamic classes content list maintained manually in config Automatic scan, no content list Assembled strings need special handling
Build engine JS-based (PostCSS plugin) Rust-based (Oxide) Faster watch rebuilds while tuning Alpine
@apply usage Free anywhere in CSS Recommended inside @layer components Clearer split between static markup and Alpine state

Mironsoft

Alpine.js interactivity for Hyvä frontends

A Hyvä frontend that needs more interactivity, but without React overhead?

We build interactive frontend components for Hyvä themes with Alpine.js, lightweight and without build-step complexity, from simple toggles to complex form flows.

Custom Components

Develop interactive Alpine.js components for specific shop requirements.

Performance Review

Review existing Alpine.js implementations for reactivity pitfalls and performance.

Team Training

Bring developers up to speed on Alpine.js patterns for Hyvä themes hands-on.

10. Summary

Tailwind v4 with Alpine.js

Core idea

Tailwind v4 replaces tailwind.config.js with @theme in CSS, so Alpine now reads design tokens via CSS custom properties instead of JS config.

Practical benefit

Dynamic accent colors and breakpoints can be read straight from the root element's computed style without duplicating data.

Biggest pitfall

Tailwind classes assembled dynamically from substrings are easily missed by v4's automatic content detection.

Recommendation

Consistently bind Alpine to design tokens through CSS variables rather than through dynamically built class names.

11. FAQ: Tailwind v4 with Alpine.js

1Does theme() still work in Tailwind v4 for Alpine access?
The function still exists for compatibility, but it is no longer the recommended path. Direct access via CSS custom properties and getComputedStyle is the native, more performant approach in v4 and removes the need for a separate JS config file.
2Do I need to touch every Alpine component when moving to v4?
Only the ones that actually read configuration values from JavaScript, for example for breakpoint calculations or dynamic color values. Components that only use static Tailwind classes in the template usually need no change at all.
3What happens if I don't fix dynamic class names in Alpine?
The v4 automatic scanner cannot resolve class strings assembled at runtime and leaves them out of the final CSS. The styling then goes missing in the production build, even though it often still works in development with unpurged CSS.
4Can I mix CSS variables and fixed Tailwind classes?
Yes, that is the common case. Static structural and layout classes stay as fixed utility classes in the template, only the genuinely dynamic values like colors or spacing move into CSS variables set via x-bind:style.
5Where should the @theme block live in a Hyvä theme?
Typically in the project-specific Tailwind source file under web/tailwind/, which imports the parent theme's values via @import and selectively overrides or extends them with its own tokens.
6Does Tailwind v4 change anything about Hyvä CSP registration for Alpine scripts?
No, every inline script that reads or sets CSS variables is still registered the normal way through the Hyvä CSP component's registerInlineScript(), regardless of which Tailwind version runs behind the scenes.
7How do I find all old JS config accesses before migrating?
A project-wide search for resolveConfig, theme( and imports of the old tailwind.config.js across JavaScript and Alpine files reliably surfaces most spots and gives a solid starting list for the migration.
8Is @apply for component classes still worthwhile in the CSS-first workflow?
Yes, for recurring static class combinations @apply inside @layer components still makes sense. For classes that depend on Alpine state, x-bind:class remains the right choice, since @apply already resolves at build time.
9Does the new Oxide build engine bring a noticeable benefit for Alpine-heavy projects?
Yes, especially with frequent incremental rebuilds while developing components with many dynamic classes, the Rust-based engine noticeably shortens the wait time in watch mode compared to the old v3 engine.
10Is there an automated migration path from v3 to v4?
Tailwind offers an upgrade tool that automates most of the config-to-CSS translation. Alpine component accesses to JS configuration values are not covered by it though and must be migrated manually as described in this article.