babel-plugin-jsx-clsx

Babel plugin for automatically adding `clsx()` to `className` attribute


Keywords
babel-plugin, babel, plugin, jsx, className, styleName, clsx, classnames, cx, css, modules, react, class
License
MIT
Install
npm install babel-plugin-jsx-clsx@1.1.0

Documentation

babel-plugin-jsx-clsx

npm GitHub License: MIT

A Babel plugin that lets you write objects and arrays directly inside the JSX className attribute — just like Svelte's class, Vue's :class, and Solid's classList — and automatically wraps them in a clsx() call. No manual import, no manual clsx() invocation, with powerful static-analysis optimizations and first-class TypeScript support.

// Before
<div className={["btn", { isSelected, btnDisabled: disabled }]} />

// After — lite
import clsx from "clsx/lite";
<div className={clsx("btn", isSelected && "is-selected", disabled && "btn-disabled")} />

// After — standard
import clsx from "clsx";
<div className={clsx("btn", { "is-selected": isSelected, "btn-disabled": disabled })} />

Table of contents

Why this plugin?

This plugin is similar to babel-plugin-jsx-classnames and babel-plugin-transform-jsx-classnames, but it is built around clsx instead of classnames, and it performs far more aggressive build-time optimization on the values you pass to className.

  • No runtime boilerplate — you never write clsx(...) by hand, and the import is added for you.
  • Smaller output — every argument is statically analyzed and simplified at build time.
  • Broad compatibility — it works with any class-name utility that shares clsx's API (including classnames itself), and with any JSX attribute (not just React's className).
  • First-class TypeScript supportbabel-plugin-jsx-classnames and babel-plugin-transform-jsx-classnames have no TypeScript support at all and will immediately type-error in a TypeScript project. This plugin ships a one-shot @types/react patch so arrays and objects in className type-check cleanly.

Installation

# npm
npm install --save-dev babel-plugin-jsx-clsx

# yarn
yarn add --dev babel-plugin-jsx-clsx

# pnpm
pnpm add --save-dev babel-plugin-jsx-clsx

# bun
bun add --dev babel-plugin-jsx-clsx

⚠️ Important: this plugin does not ship any runtime class-name utility. You must also install a runtime library yourself — for example clsx (recommended) or classnames:

npm install clsx
# or
npm install classnames

If you don't install one, the plugin will fall back to calling a same-named global variable (see Any runtime library).

Usage

Add the plugin to your Babel configuration:

// babel.config.json
{
  "plugins": ["babel-plugin-jsx-clsx"]
}

With options:

{
  "plugins": [
    [
      "babel-plugin-jsx-clsx",
      {
        "package": "clsx",
        "identifier": "clsx"
      }
    ]
  ]
}

For frameworks that ship their own JSX transform (Vite, Next.js, esbuild, etc.), wire it into their Babel plugin list the same way you would any other Babel plugin.

Features

1. Arrays and objects in className

Pass an array or an object straight to className. The plugin wraps the value in clsx(...) and inserts the import automatically.

// Before
<div className={["btn", "btn-primary", { active: isActive }]} />

// After
import clsx from "clsx";
<div className={clsx("btn", "btn-primary", isActive && "active")} />

Nested arrays and objects are flattened recursively.

// Before
<div className={[foo, "bar", [hello, world, { baz }]]} />

// After
import clsx from "clsx";
<div className={clsx(foo, "bar", hello, world, baz && "baz")} />

2. Static analysis & simplification

Every argument is statically analyzed and simplified. The plugin:

  • removes falsy arguments (false, null, undefined, NaN, "", 0, 0n, void x, !truthy, …);
  • removes duplicate class names (see below);
  • flattens arrays and objects automatically;
  • simplifies logical and ternary expressions (true && "foo""foo", false ? "foo" : "bar""bar", foo && foofoo, …);
  • flattens objects into logical-AND expressions ({ foo }foo && "foo"), which is compatible with the clsx/lite package and slightly improves runtime performance.

When everything reduces to static strings, the expression container is dropped entirely and the attribute becomes a plain string — no clsx call, no import.

// Before
<div className={["btn", { foo: true, bar: false }, "active"]} />

// After — no clsx import at all
<div className="btn foo active" />
// Before
<div className={{ foo: true, bar: false }} />

// After — no clsx import at all
<div className="foo" />
// Before
<div className={[true && "foo", "bar"]} />

// After — no clsx import at all
<div className="foo bar" />

Order of class names is preserved

All simplification is order-preserving: the plugin never reorders class names. Deduplication keeps the last occurrence of a name in its original position, and adjacent object literals are merged while non-adjacent ones stay separate:

// Before
<div className={[foo, { bar }, { baz }, qux, { quux }]} />

// After — adjacent objects are merged, non-adjacent ones are not, { flattenObject: false }
import clsx from "clsx";
<div className={clsx(foo, { bar, baz }, qux, { quux })} />

Here { bar } and { baz } sit next to each other, so they merge into { bar, baz }; { quux } is separated by qux, so it is left untouched. (This example shows the object-preserving flattenObject: false form; with the default flattenObject: true, order is still preserved but each object is flattened into a cond && "class" expression.)

In plain HTML the written order of class names does not affect styling — CSS specificity is determined by the order of declarations in the stylesheet, not by the order of class names in the markup. Reordering would usually be harmless, but a few edge cases can break if the order changes:

  • Attribute selectors such as [class^="foo"] match against the exact class string, so any reordering changes what the selector matches.
  • Third-party libraries that depend on order. For example Tailwind CSS generates style rules in the order class names appear in the string, so a later class overrides an earlier one — reordering would change which styles win.

3. Class-name deduplication

When the same class name appears more than once, the later declaration wins. This lets an object later in the list override an earlier static class, and lets a false value remove a class that was declared earlier.

// Before
<div className={[foo, bar, { bar: isActive, foo: false }]} />

// After
import clsx from "clsx";
<div className={clsx(isActive && "bar")} />

Here bar was first declared unconditionally, but the later { bar: isActive } overrides it (so bar is now conditional), and { foo: false } removes foo entirely. For static strings the last occurrence wins too:

// Before
<div className={["foo", "bar", "foo"]} />

// After
<div className="bar foo" />

4. Comma expressions

In principle you may also use a comma (sequence) expression directly in className, which lets you drop the array brackets:

// Before
<div className={foo, "bar", bar} />

// After
import clsx from "clsx";
<div className={clsx(foo, "bar", bar)} />

However, this is an abuse of JavaScript syntax and is not recommended. TypeScript statically analyzes comma expressions incorrectly — it reports every expression before the last comma as an "unused expression" — so the code will type-error. Prefer the array form.

5. Any runtime library

The plugin is not tied to clsx. Any class-name utility with a compatible API works out of the box — including the classic classnames. Just change the package option:

{
  "plugins": [
    ["babel-plugin-jsx-clsx", { "package": "classnames" }]
  ]
}

You can also control the imported identifier to avoid clashing with an existing variable in the file. Both default and named exports are supported via the identifier option:

// default export
{ "package": "clsx", "identifier": "cx" }
// Becomes →
import cx from "clsx";

// named export
{ "package": "clsx", "identifier": ["cx"] }
// Becomes →
import { cx } from "clsx";

// renamed named export
{ "package": "clsx", "identifier": ["clsx", "cx"] }
// Becomes →
import { clsx as cx } from "clsx";

// namespace import
{ "package": "clsx", "identifier": ["*", "cx"] }
// Becomes →
import * as cx from "clsx";

The plugin never bundles a runtime library. Install the library you want to use yourself. If you don't, the generated code will call the same-named global variable. Set package to null or "" to suppress the import statement entirely and rely on a global:

{ "package": null }  // or ""
// Before
<div className={{ foo }} />

// After — no import added
<div className={clsx(foo && "foo")} />

If a file already imports the target package manually, the plugin detects it and skips adding a duplicate import. Use skipPackages to extend this detection to additional package names.

6. Custom attributes

The plugin is not limited to React's className. It also works on any other JSX attribute, so third-party libraries with their own class attribute are supported. A common example is React CSS Modules, which uses styleName for CSS Module class names:

{
  "plugins": [
    ["babel-plugin-jsx-clsx", { "attributes": ["className", "styleName"] }]
  ]
}
// Before
<div styleName={["foo", { bar: active }]} />

// After
import clsx from "clsx";
<div styleName={clsx("foo", active && "bar")} />

className and styleName are the default attributes. You can replace or extend the list (e.g. add class for Vue JSX or classList for Solid).

7. kebabizeKey

Inside an object literal you may declare class names in camelCase and have them automatically converted to kebab-case in the output. This lets you avoid quoting keys with hyphens and enables the object-shorthand syntax:

// Before
<div className={{ isSelected }} />

// After — { kebabizeKey: true }, the default
import clsx from "clsx";
<div className={clsx(isSelected && "is-selected")} />

The motivation is:

  • Object keys have character restrictions — you can't write { "is-selected": isSelected } as unquoted like { is-selected: isSelected } or shorthand, but { isSelected } is valid. Converting the key to kebab-case recovers the CSS convention while keeping the source terse.

It is worth noting that:

  • Arrays and plain strings are never converted. Converting every string would be pointless (plain strings have no such key restriction) and actively harmful: e.g. Styled Components injects unique class names with case-sensitive hashes such as sc-aBCdEf. Kebabizing those would corrupt them into sc-a-bc-d-e-f and break the styles.

Set kebabizeKey: false to disable it.

Note that conversion applies only to object keys, never to array items or standalone strings.

8. flattenObject

By default objects are flattened into logical-AND expressions, e.g. { foo }foo && "foo". This is compatible with the smaller clsx/lite package and slightly faster at runtime.

// Before
<div className={["btn", { isSelected }]} />

// After — { flattenObject: true }, the default
import clsx from "clsx";
<div className={clsx("btn", isSelected && "is-selected")} />

Set flattenObject: false to keep the object form (deduplication and removal of falsy class names still apply):

// Before
<div className={["btn", { isSelected }]} />

// After — { flattenObject: false }
import clsx from "clsx";
<div className={clsx("btn", { "is-selected": isSelected })} />

Regardless of whether objects are flattened or kept, the order of class names you wrote is always preserved — see Order of class names is preserved.

⚠️ Both flattenObject and kebabizeKey are purely static transformations. They require the object to be written inline as an object literal in the className attribute. If you reference an external object variable, its type can't be statically analyzed, so the variable is passed through untouched:

// Before
const classes = { foo: true };
<div className={classes} />

// After — left as-is, with clsx wrapper only
import clsx from "clsx";
const classes = { foo: true };
<div className={clsx(classes)} />
// Before
const classes = { bar: true };
<div className={["foo", classes]} />

// After — left as-is
import clsx from "clsx";
const classes = { bar: true };
<div className={clsx("foo", classes)} />

If you are using clsx/lite, such an external object would be coerced to the string "[object Object]" and placed directly in the class attribute. Like this:

<div class="foo [object Object]"></div>

If you must pass external object variables, use the full clsx package instead.

9. TypeScript compatibility

The plugin works fine in plain JavaScript projects. The problem is TypeScript: @types/react hard-codes className to string | undefined, so any array or object value is immediately a type error:

<div className={["foo", { bar: active }]} />
// ✗ Type 'string | { bar: boolean }[]' is not assignable to type 'string | undefined'.

The usual workaround — declaring a .d.ts file — doesn't help, because declaration merging can add new types (new elements/attributes) but cannot change an existing, incorrectly-typed property. The only viable solution is to patch @types/react itself, and this plugin ships a one-shot command to do exactly that.

Patching @types/react

After installing the plugin, run:

npx babel-plugin-jsx-clsx patch
# or
npx babel-plugin-jsx-clsx --patch
# or
npx babel-plugin-jsx-clsx --patch-react-types

This rewrites the className type to accept arrays and objects:

className?: string | any[] | Record<string, any> | undefined;

To restore the original type definition:

npx babel-plugin-jsx-clsx unpatch
# or
npx babel-plugin-jsx-clsx --unpatch
# or
npx babel-plugin-jsx-clsx --unpatch-react-types

The patch command supports all major package managers:

Package manager How it patches How it removes the patch
npm Directly edits index.d.ts in node_modules. Reinstall @types/react forcibly.
yarn classic Directly edits index.d.ts in node_modules (no yarn patch support). Reinstall @types/react forcibly.
yarn berry Uses yarn patch and yarn patch-commit. Remove the .patch file in the patches folder and the declaration in the patchedDependencies property from package.json, and then reinstall @types/react forcibly.
pnpm Uses pnpm patch and pnpm patch-commit. Uses pnpm patch-remove.
bun Uses bun patch and bun patch --commit. Remove the .patch file in the patches folder and the declaration in the patchedDependencies property from package.json, and then reinstall @types/react forcibly.

The package manager is auto-detected from the lock file in the current directory.

⚠️ Note: a patch is version-specific. Every time you upgrade React (and thus @types/react), the patch is invalidated — re-run npx babel-plugin-jsx-clsx patch afterwards.

Plugin Options

Option Type Default Description
attributes string[] ["className", "styleName"] JSX attributes to transform.
package string | null | "" "clsx" Package to import from. null / "" disables the auto-import (falls back to a global).
skipPackages string[] ["clsx", "clsx/lite", "classnames", "classnames/dedupe", "classnames/bind"] If a file already imports one of these, no import is added.
identifier string | [imported, local?] "clsx" Identifier used for the import and call. Tuple form controls named/renamed/namespace imports.
kebabizeKey boolean true Convert camelCase object keys to kebab-case. Object keys only.
flattenObject boolean true Flatten objects into cond && "class" logical-AND expressions (required by clsx/lite).

identifier reference

identifier Import statement
"foo" import foo from "package";
["foo"] import { foo } from "package";
["foo", "bar"] import { foo as bar } from "package";
["default", "foo"] import { default as foo } from "package";
["*", "foo"] import * as foo from "package";

Comparison with similar plugins

babel-plugin-jsx-clsx babel-plugin-transform-jsx-classnames babel-plugin-jsx-classnames
Runtime utility clsx
(configurable)
clsx
(built-in runtime helper)
classnames
Static-analysis simplification (dedupe, drop falsy, flatten)
{ dedupe: true }
for runtime only
Collapses to a plain string attribute when fully static
clsx/lite support via object flattening
camelCase → kebab-case object keys
Custom attributes (styleName, classList, …)
Custom runtime package / identifier
TypeScript support
(built-in @types/react patch)

(none — type errors)

(none — type errors)
Built-in @types/react patch command

License

MIT