A function that returns a class string and a compiler that emits CSS solve different problems, even when both eventually produce a `class` attribute in the browser.

That distinction matters when a Tailwind component recipe starts like this:

const button = tv({
  base: 'inline-flex rounded-md',
  variants: {
    color: {
      success: 'bg-green-500 hover:bg-green-700',
    },
    disabled: {
      true: 'pointer-events-none opacity-50',
    },
  },
  compoundVariants: [
    {
      color: 'success',
      disabled: true,
      class: 'bg-green-300 hover:bg-green-300',
    },
  ],
});

It is tempting to expect a build step to turn that recipe into a semantic selector such as:

.button-color-success {
  /* generated declarations */
}

That is not what `tailwind-variants` does. Its output is a class string. The CSS behind those utility classes must already be generated by Tailwind. The same boundary applies to `clsx`: it chooses and concatenates class names, but it does not author new CSS rules.

Class composition operates on names

`clsx` is a conditional class-string utility. Given strings, arrays, and conditional objects, it produces a space-separated string.

import clsx from 'clsx';

const classes = clsx(
  'button',
  active && 'button-active',
  disabled && 'button-disabled',
);

If `active` is true and `disabled` is false, the result is equivalent to:

button button-active

Nothing in that operation defines what `.button` or `.button-active` means. Those selectors must come from another stylesheet or CSS generation pipeline.

The data flow is:

conditions
   |
   v
clsx(...)
   |
   v
"class names"
   |
   v
DOM class attribute

The function works at the string layer, not the CSS rule layer.

Tailwind Variants adds a variant model, not a CSS compiler

`tailwind-variants` adds structure on top of Tailwind class strings. A recipe can define base classes, variant axes, boolean states, defaults, slots, and compound variants.

import { tv } from 'tailwind-variants';

const button = tv({
  base: 'inline-flex rounded-md px-4 py-2',
  variants: {
    color: {
      success: 'bg-green-500 text-white hover:bg-green-700',
      neutral: 'bg-zinc-200 text-zinc-900',
    },
    size: {
      sm: 'text-sm',
      lg: 'text-lg',
    },
  },
  defaultVariants: {
    color: 'neutral',
    size: 'sm',
  },
});

Calling the recipe returns a class string:

button({ color: 'success', size: 'lg' });

Conceptually:

inline-flex rounded-md px-4 py-2
bg-green-500 text-white hover:bg-green-700
text-lg

The default build can also resolve conflicting Tailwind utilities. That still operates on class tokens. It does not convert the recipe into a new selector such as `.button-success-lg`.

This separation is useful because the same recipe can feed multiple frameworks:

<!-- conceptual output -->
<button class="inline-flex rounded-md px-4 py-2 bg-green-500 text-white text-lg">
  Save
</button>

Vue can bind the returned string to `:class`, Svelte can bind it to `class`, and vanilla JavaScript can assign it to `element.className`. The recipe remains framework-agnostic because its product is only a string.

Tailwind generates utilities from source detection

Tailwind sits at a different stage. It scans configured sources for utility candidates and generates CSS for the classes it recognizes.

When a recipe contains:

color: {
  success: 'bg-green-500 hover:bg-green-700',
}

Tailwind needs to see those utility strings in a source path that participates in scanning. Tailwind Variants does not replace that requirement.

The build relationship is closer to:

tv() source strings -------------------+
                                       |
Tailwind source detection              |
        |                              |
        v                              |
generated utility CSS                  |
        |                              |
        +------------------------------+
                       |
                       v
             browser matches classes

The recipe selects utility names at runtime or render time. Tailwind generates the corresponding CSS during the build.

Semantic classes require an explicit CSS authoring step

If the intended HTML is:

<button class="button-color-success">Save</button>

then `.button-color-success` must be authored or generated as a CSS selector.

With Tailwind, one direct option is a custom CSS layer using `@apply` where appropriate:

.button {
  @apply inline-flex rounded-md px-4 py-2;
}

.button-color-success {
  @apply bg-green-500 text-white hover:bg-green-700;
}

.button-disabled {
  @apply pointer-events-none opacity-50;
}

.button-color-success.button-disabled {
  @apply bg-green-300 hover:bg-green-300;
}

The HTML can then use semantic classes:

<button class="button button-color-success">Save</button>

This is a different architecture from calling `button({ color: ‘success’ })`. One centralizes state selection in JavaScript; the other exposes selectors that HTML can reference directly.

Neither model is inherently more correct. The important point is that converting a JavaScript variant recipe into semantic selectors requires a generator specifically designed to do that. `tailwind-variants` is not such a generator.

CSS Modules rename selectors during the build

CSS Modules participate in CSS transformation. A source file can contain readable local class names:

/* button.module.css */
.button {
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
}

.success {
  background: green;
  color: white;
}

Application code imports a mapping:

import styles from './button.module.css';

element.className = styles.button + ' ' + styles.success;

A bundler can transform the local names into scoped names such as:

<button class="_button_1a2b3_1 _success_1a2b3_6">Save</button>

The precise output depends on the CSS Modules configuration. In Vite, CSS Module naming can be configured with options including `generateScopedName` and `hashPrefix`.

The critical difference from `clsx` is that a build plugin owns both sides of the mapping:

.button in source CSS
       |
       v
CSS Modules transform
       |
       +--> generated selector in CSS
       |
       +--> generated name exported to JavaScript

That is why hashed or scoped class names remain synchronized with their declarations.

Random names are usually the wrong requirement

A production build normally needs deterministic scoped names, not true randomness.

Suppose a class name changed unpredictably on every invocation:

build 1 -> _x7k2p
build 2 -> _q9m4c

Changing names between builds is not itself a problem if the HTML and CSS are generated together. The problem appears when names change independently or at runtime without matching CSS.

Build tooling therefore commonly derives names from stable inputs such as the local class name, file path, content, or a hash. The result may look random to a human while still being reproducible enough for the build pipeline.

The required invariant is:

generated class referenced by markup
              ==
generated selector emitted in CSS

Obfuscation is secondary. Synchronization is the actual correctness property.

StyleX is a compiler-backed styling system

StyleX moves further toward compiler-owned CSS generation. Styles are written as JavaScript objects:

import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
  button: {
    paddingBlock: '0.5rem',
    paddingInline: '1rem',
    borderRadius: '0.375rem',
  },
  success: {
    backgroundColor: 'green',
    color: 'white',
  },
});

The StyleX compiler extracts static styles into collision-free atomic CSS at compile time. Style application code then uses the generated representation through APIs such as `stylex.props()`.

That pipeline is structurally different from `clsx`:

StyleX object
    |
    v
StyleX compiler
    |
    +--> static atomic CSS
    |
    +--> generated references used by application code

StyleX documentation explicitly describes static CSS generation at compile time and no runtime style injection for extracted styles.

This is why StyleX can legitimately be described as a CSS generation system, while `clsx` cannot.

StyleX and Tailwind can coexist, but they own different CSS

A project can technically contain both systems:

<div className="grid gap-4 p-6">
  <button {...stylex.props(styles.button, styles.success)}>
    Save
  </button>
</div>

In this arrangement:

Tailwind
  -> utility classes such as grid, gap-4, p-6

StyleX
  -> compiler-generated atomic classes

Both ultimately affect the same DOM, but their build pipelines do not merge into one shared variant compiler. A Tailwind utility is not a StyleX style declaration, and a StyleX object does not become a Tailwind utility.

Using both systems on the same property also creates an ownership problem. If Tailwind and StyleX each set `background-color`, the final result depends on generated CSS order and the systems’ cascade behavior. A cleaner boundary assigns each property domain to one system or keeps the systems separated by component scope.

Vue and Svelte do not change the boundary

Framework syntax changes how a class string reaches the DOM, not what the library produces.

Vue:

<script setup>
import { tv } from 'tailwind-variants';

const button = tv({
  variants: {
    color: {
      success: 'bg-green-500 text-white',
    },
  },
});
</script>

<template>
  <button :class="button({ color: 'success' })">
    Save
  </button>
</template>

Svelte:

<script>
  import { tv } from 'tailwind-variants';

  const button = tv({
    variants: {
      color: {
        success: 'bg-green-500 text-white',
      },
    },
  });
</script>

<button class={button({ color: 'success' })}>
  Save
</button>

In both cases, `tv()` still returns class names. The framework does not turn the recipe into a stylesheet.

CSS Modules also preserve their build-time role in these frameworks when the bundler supports them. The syntax around the imported mapping may differ, but the invariant remains the same: the CSS transform generates a selector and exports the corresponding name to application code.

Pick the tool by the layer that needs control

The tools discussed here occupy four distinct layers:

Tool Primary output Creates CSS rules? Typical role
`clsx` class string No conditional class composition
`tailwind-variants` Tailwind class string No typed component variants and class conflict handling
CSS Modules scoped class mapping + transformed CSS Yes local selector scoping and build-time class naming
StyleX generated references + atomic CSS Yes compiler-backed application styling

The architectural question is therefore not whether a tool can produce an unusual-looking class name. It is which stage owns the CSS rule.

If HTML must reference a stable semantic class, author or generate that selector.

If component state needs to choose among existing Tailwind utilities, a variant recipe is appropriate.

If class names must be scoped or hashed while staying synchronized with CSS, use a build-time transform such as CSS Modules.

If styles themselves should be authored in JavaScript and extracted into atomic CSS, use a compiler-backed system such as StyleX.

Keeping those layers separate prevents a common category error: expecting a class-string utility to behave like a CSS compiler.