Known limitations โ
Every cell marked โ or โ ๏ธ in our compatibility matrix and comparison table is documented here with the technical reason, the workaround when one exists, and the tracking issue when the situation may change.
If you hit a limitation that is not on this page, please open an issue so we can either fix it or document it here.
Class extraction limitations โ
Dynamic template literals (`bg-${color}-500`) โ
Marked โ Not extracted in the Compatibility reference (ยง JSX/React Patterns).
Why dynamic template literals are not extracted
Root cause : the obfuscator extracts class names by statically analysing source code at build time. A template literal whose value depends on a runtime variable (e.g. `bg-${color}-500`) is not knowable until the user's app actually runs โ by which point the build has long finished.
Workaround : enumerate every possible value at build time so the obfuscator can see them all as static strings :
// โ Won't extract
<div className={`bg-${color}-500`} />
// โ
Will extract
<div className={color === "red" ? "bg-red-500" : "bg-blue-500"} />
// โ
Or via a lookup map
const COLORS = { red: "bg-red-500", blue: "bg-blue-500" };
<div className={COLORS[color]} />
// โ
Or via the safelist (`exclude` option)
// in your obfuscator config:
// exclude: [/^bg-(red|blue|green)-500$/]Tracked in : intentional design decision. Static-only extraction is the only way to guarantee a deterministic mapping that a downstream consumer can reverse without running the application.
Variable-bound className={styles} โ
Marked โ Not extracted in the Compatibility reference (ยง JSX/React Patterns).
Why variable-bound className is not extracted
Root cause : same as the template-literal case โ the obfuscator does not execute your code, so it cannot know what styles resolves to at runtime. Even with sophisticated dataflow analysis, the variable could come from props, a fetch, or a JSON blob โ all unknowable at build time.
Workaround :
- If
stylesis a constant inside the same file, the AST extractor will follow simple references โ writeconst styles = "bg-blue-500 text-white"instead of importingstylesfrom another module. - For props-driven styling, prefer one of the supported class utilities (
cn(),clsx(),cva()) which the extractor knows how to inspect.
Tracked in : intentional, see above.
Vue :class bindings with literal nested quotes โ
Marked โ ๏ธ Partial in some scenarios.
Why some nested-quote Vue bindings extract differently
Root cause : in PR #45 the regex was simplified from [^"]*(?:'[^']*'[^"]*)* to a flat [^"]* to fix a critical js/redos catastrophic-backtracking vulnerability (CodeQL severity: error). The trade-off is that Vue :class attribute values containing nested quotes (e.g. :class="aria-label='hi' bg-blue-500") are now truncated at the first '.
Workaround : structure your :class bindings as object/array syntax (which is the canonical Vue pattern anyway) :
<!-- โ ๏ธ Partial extraction โ only "aria-label=" is captured -->
<div :class="aria-label='hi' bg-blue-500"></div>
<!-- โ
Full extraction -->
<div :class="{ 'bg-blue-500': isActive }"></div>
<div :class="['flex', isActive && 'bg-blue-500']"></div>Tracked in : the security trade-off is intentional. The pre-PR-#45 form would freeze a downstream user's build with a malformed Vue file.
tv() (tailwind-variants) base + variants + compoundVariants โ
Currently broken โ fix in flight
Root cause : the regex in packages/tailwindcss-obfuscator/src/extractors/jsx.ts:386-409 matches the surface form tv({ base: "..." }) but in practice does not pick up the variant strings. Discovered while building apps/test-tailwind-variants/ โ only inline template-literal classes get obfuscated, the entire base / variants / compoundVariants content is left as raw bg-blue-600 / text-white etc.
Workaround until fixed : either
- inline the variant strings as constants and reference them (the AST extractor can follow same-file constants),
- or use
cva()instead oftv()โcva()works correctly today and is exercised end-to-end inapps/test-shadcn-ui/.
Tracked in : issue #61. README claim "tv() recognised out of the box" is currently misleading โ will be re-honored once #61 ships.
Framework version coverage โ
The README advertises a wide design-intent range for several frameworks. The actual versions tested in CI on every release are narrower โ see the Compatibility reference (ยง Version-range claims vs. tested baselines) for the exact matrix.
Next.js v13 / v14 / v15 โ not in the test matrix โ
Why v13 / v14 / v15 are advertised but not tested
Root cause : the package's plugin core is bundler-agnostic (built on unplugin) and does not depend on Next.js internals โ every version since v13 should work in principle. We simply don't have CI test apps for v13โv15 yet, only v16.
Workaround : if you ship Next.js v13โv15 in production, please confirm by running node scripts/verify-obfuscation.mjs against your own build. If anything breaks, open an issue and we'll add a regression test app for your version.
Tracked in : issue #55 โ phase-2 test apps coverage.
Nuxt v3 โ not in the test matrix โ
Why Nuxt v3 is advertised but not tested
Root cause : the Nuxt v3 ESM-only bundling differs in a few subtle ways from v4 (different module-graph hooks, slightly different config surface). v4.4 is what we exercise on CI ; v3 should still work because the plugin uses the public @nuxt/kit module hooks, but it's not regression-tested.
Workaround : same as Next.js โ verify locally and report any regression.
Tracked in : issue #55.
Astro v4 / v5 โ not in the test matrix โ
Why Astro v4โv5 are advertised but not tested
Root cause : Astro v4 introduced the integration system the plugin relies on, and v5 + v6 are incremental additions. The plugin uses the same Astro hooks across all three versions and should work.
Workaround : same as above.
Tracked in : issue #55.
Vite v4 / v5 / v6 / v7 โ not in the test matrix โ
Why Vite v4โv7 are advertised but only v8 is tested
Root cause : Vite's plugin API is stable across v4โv8. The plugin uses transform() and generateBundle() hooks which haven't changed. v8 is what we test ; older versions should work but are not regression-tested.
Workaround : same as above.
Tracked in : issue #55.
Svelte v4 (pre-runes) โ not in the test matrix โ
Why pre-runes Svelte 4 is advertised but not tested
Root cause : Svelte 5 introduced runes (the $state / $derived reactivity primitives). The class extractor handles class:directive syntax (which exists in both v4 and v5), but the test suite uses Svelte 5.55 with runes. A Svelte 4 codebase using class:directive should obfuscate fine.
Workaround : same as above.
Tracked in : issue #55.
Tailwind v4 features known to be incomplete โ
A handful of Tailwind v4 features have less-than-complete support today. The big-picture v4 audit lives in the v4 Quick Reference page. Each individual gap below has its own card.
Wildcard variants *: and **: โ fully supported as of v2.0.x โ
Marked โ
in the Compatibility reference. No card needed.
Logical-axis utilities (pbs-*, mbs-*, inset-bs-*) โ
Status: supported and obfuscated since v2.0.x
The Tailwind v4 logical-axis utilities (pbs-4 for padding-block-start, mbs-2 for margin-block-start, etc.) are recognised by the same regex that handles directional utilities. No special handling needed.
not-* / in-* / nth-* variants โ supported, edge cases possible โ
Marked โ ๏ธ Partial in the v4 Features Analysis page.
Why some uncommon not-* / in-* / nth-* patterns may not extract
Root cause : the basic shapes (not-hover:bg-blue-500, in-data-[state=open]:block, nth-2:bg-gray-100) are all in the test matrix and obfuscate correctly. Combinations with deeply-nested arbitrary values (e.g. not-[*[data-foo='bar baz']]:flex) may not extract because the regex doesn't unfold every nesting depth.
Workaround : if your build emits a class whose CSS is generated by Tailwind but the class itself is not in .tw-obfuscation/class-mapping.json, list it in your obfuscator config under exclude so it survives unchanged. Alternatively, add a regression test case to the project โ we'll widen the regex.
Tracked in : the js/polynomial-redos audit (issue #50) is the place where these regex patterns get reviewed for both ReDoS safety and depth-of-nesting coverage.
CSS variable shorthand bg-(--my-var) โ supported as of v2.0.x โ
Marked โ
in the Compatibility reference. Tailwind v4's bg-(--brand-color) parentheses shorthand is recognised. No card needed.
@plugin and @utility directives in user-authored CSS โ
Why classes registered via @plugin / @utility may not obfuscate
Root cause : if a user defines a custom utility via @utility my-thing { โฆ } in their own CSS, that utility is generated by Tailwind at build time and appears in the final stylesheet. The obfuscator's CSS extractor (packages/tailwindcss-obfuscator/src/extractors/css.ts) reads the emitted CSS and picks them up. However, the HTML/JSX extractor doesn't know about user-defined utilities until it sees them used as class names โ if they're used in the same way as Tailwind utilities (just <div class="my-thing">), they will be obfuscated too. If they're applied via @apply from another component-utility CSS layer, the situation is more complex and may not round-trip cleanly.
Workaround : prefer using @apply inside @layer components { โฆ } so the rendered class names appear directly in the HTML and are obfuscated like any other utility. Avoid chained @apply from inside @plugin definitions.
Tracked in : no current issue ; please open one with a repro if this affects you.
Comparison-with-others scoring justifications โ
Cells in docs/research/comparison.md marked โ for the competitor (tailwindcss-mangle, Obfustail) reflect what is true about those projects today. We don't claim the competitors will never implement these โ we claim they don't today. Each โ in that table corresponds to a specific verifiable absence:
- AST-based JSX/TSX extraction (Babel) :
tailwindcss-mangleuses regex string-matching,Obfustailuses per-string regex replace. Neither parses the AST. Verifiable by reading their source. - Tailwind v4 support without config file :
Obfustailrequirestailwind.config.jswhich v4 deprecated. Verifiable by attempting to use it on a v4 project. - Per-utility obfuscation :
Obfustailrewrites whole strings, not individual utilities, so two strings sharing 90% of their utilities still get fully different obfuscated forms (no reuse). Verifiable by inspecting their output. - Provenance / OpenSSF / SBOM : verifiable by checking each competitor's npm metadata and GitHub repo. As of 2026-04, neither competitor publishes with provenance attestations or has an OpenSSF Scorecard score.
If any of these claims becomes outdated (e.g. a competitor ships v4 support tomorrow), the comparison table โ and this page โ must be updated in the same PR.
Things we will not implement โ
Some absences are by design, not by oversight. They live here so you don't waste time opening an issue for them.
Runtime obfuscation โ
By-design : the obfuscator is build-time only
Why : a runtime obfuscator would need to ship a JavaScript class-mapping table to every visitor, defeating the entire bundle-shrink benefit (the table grows with the design system). It would also defeat reverse-engineering protection โ the mapping is right there in the bundle.
Alternative : if you need per-request randomisation (e.g. to defeat scrapers that scrape your CSS class names today and your obfuscated names tomorrow), use a CDN-side rewrite layer instead of a runtime obfuscator. Several edge platforms can do this in 10 lines of Cloudflare Worker / Vercel Middleware.
Deobfuscation in the browser DevTools โ
By-design : we don't ship a DevTools extension
Why : the .tw-obfuscation/class-mapping.json file emitted at build time is the deobfuscation. Open it in any text editor or pipe it through jq to reverse a tw-XXX to its original utility name.
Alternative : if you want a browser overlay that decodes classes live (e.g. for debugging in production), build it on top of the mapping JSON โ it's a 50-line userscript. We'd happily link to a community extension if one exists.
Obfuscation that survives a downstream tailwind-merge call at runtime โ
By-design : tailwind-merge runs after the bundle is produced, so it can't be obfuscated retroactively
Why : twMerge() (from the tailwind-merge library) is a runtime function that resolves Tailwind utility conflicts (e.g. twMerge("p-4 p-2") โ "p-2"). The obfuscator runs at build time and produces a final mapping ; if twMerge then receives obfuscated strings at runtime, it cannot resolve their conflicts because it doesn't know what each tw-XXX originally meant.
Workaround : use twMerge BEFORE the obfuscator sees the final string. The standard pattern is to wrap twMerge inside cn() (the shadcn convention) which the obfuscator's AST extractor knows how to inspect โ it sees the Tailwind utility string at build time, before twMerge is even invoked at runtime.
// โ
Recommended โ twMerge runs at runtime on already-obfuscated strings,
// but the AST extractor saw the original utilities through cn() at build time
import { cn } from "@/lib/utils";
<div className={cn("p-2 p-4", isActive && "p-6")} />;Tracked in : intentional, see test-shadcn-ui for the canonical pattern.
How to keep this page honest โ
Whenever a โ / โ ๏ธ / "Not supported" / "Partial" lands in the documentation, the project's documentation rule requires it to be paired with a card on this page (or a one-line link to the existing card). If you find a bare โ anywhere on the docs site without a card or link explaining it, please open a doc bug.