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 })} />- Why this plugin?
- Installation
- Usage
- Features
- Options
- Patching
@types/react - Comparison with similar plugins
- License
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 (includingclassnamesitself), and with any JSX attribute (not just React'sclassName). -
First-class TypeScript support —
babel-plugin-jsx-classnamesandbabel-plugin-transform-jsx-classnameshave no TypeScript support at all and will immediately type-error in a TypeScript project. This plugin ships a one-shot@types/reactpatch so arrays and objects inclassNametype-check cleanly.
# 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 exampleclsx(recommended) orclassnames:
npm install clsx
# or
npm install classnamesIf you don't install one, the plugin will fall back to calling a same-named global variable (see Any runtime library).
Add the plugin to your Babel configuration:
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.
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")} />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 && foo→foo, …); -
flattens objects into logical-AND expressions (
{ foo }→foo && "foo"), which is compatible with theclsx/litepackage 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" />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 exactclassstring, 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.
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" />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.
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
packagetonullor""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.
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).
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 intosc-a-bc-d-e-fand break the styles.
Set kebabizeKey: false to disable it.
Note that conversion applies only to object keys, never to array items or standalone strings.
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.
⚠️ BothflattenObjectandkebabizeKeyare purely static transformations. They require the object to be written inline as an object literal in theclassNameattribute. 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
clsxpackage instead.
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.
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-typesThis 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-typesThe 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-runnpx babel-plugin-jsx-clsx patchafterwards.
| 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 |
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"; |
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 |
✅ | ❌ | ❌ |