mrkeyoor.com_
Thu 06 Aug 02:41 UTC
npmWeb Frontendupdated 06 Aug 2026

classnames

classnames is one function that turns a pile of mixed arguments into a space-separated className string. Pass it strings and they get joined. Pass it an object and every key whose value is truthy is included, so classNames({ active: isActive, disabled: !enabled }) gives you exactly the classes that apply. Arrays are flattened recursively, and null, undefined, false, 0, and empty strings are dropped silently, which is what lets you write classNames('btn', props.className, isOpen && 'is-open') without guarding anything. That is the whole library: 832 bytes minified, 474 gzipped, zero dependencies, TypeScript definitions in the box. It was the official replacement for React's old classSet addon, which is why it turns up in almost every React codebase of a certain age. Two extra builds ship alongside the main one: classnames/dedupe, which removes duplicates and lets a later falsy value cancel an earlier truthy one, and classnames/bind, which maps short names through a CSS Modules style object.

Verdict

classnames is a well-made utility that solved a real problem and then stopped changing, which is the correct outcome for a function this small. If it is already installed, keep using it; if you are picking one today, clsx does the same thing in less code with an ESM build.

API stability5/5The signature has not moved through the entire 2.x line, the README commits to SemVer, and with no release since December 2023 there is nothing to migrate. Whatever you write against it today will keep working.
Docs4/5The README is a single page that covers every accepted input shape with runnable one-liners, documents both alternate builds, and includes a before-and-after React example, plus a separate HISTORY.md changelog. What is missing is any guidance on the Tailwind ordering problem or on ESM consumption, which are the two questions most new users arrive with.
Maintenance3/5The repository still shows commits as recently as 5 August 2026 and the queue is tiny at 4 open issues (10 counting PRs), but the last published version is 2.5.1 from December 2023. For a function this small that is defensible, though it does mean long-standing requests such as an ESM build are not going to ship.
Ecosystem5/5Around 31.3 million downloads a week, MIT licensed, sponsored by Thinkmill, and a transitive dependency across a wide slice of the React component ecosystem, so it is already present in most node_modules trees whether you chose it or not.

Use it if

  • It is already in your dependency tree because some component library pulled it in, and you would rather use the copy you are shipping than add a second utility that does the same job
  • You are writing conditional className props in React and want the object form, which reads better in review than a chain of ternaries concatenated with template strings
  • You use CSS Modules and want the bind variant, so cx('base', { error: hasError }) resolves through your styles object instead of you writing styles.base and styles.error by hand
  • You need a build that works from a plain script tag or RequireJS: the main file registers a global classNames and defines an AMD module, which the modern alternatives do not do
  • Stability is the requirement. No dependencies, no build step, an API that has not changed in years, and a maintainer statement in the README that performance implications are reviewed before every release
Skip it if

Setup reality

npm install classnames and you are done: no peer dependencies, no runtime dependencies, and TypeScript definitions bundled as index.d.ts, bind.d.ts, and dedupe.d.ts, so there is no @types package to chase. The subpaths classnames/bind and classnames/dedupe are both declared in the exports map, which means they resolve correctly under strict Node resolution and under bundlers that honor exports. The real friction is elsewhere. Because it is CommonJS with no ESM build, a native ESM Node script must use the default import; named imports fail, and some strict TypeScript setups need esModuleInterop or allowSyntheticDefaultImports before the default import type-checks. Editors do not autocomplete Tailwind classes inside a function call by default, so Tailwind CSS IntelliSense needs a tailwindCSS.experimental.classRegex entry and prettier-plugin-tailwindcss needs classnames added to its tailwindFunctions option, or class sorting and validation silently stop at your component boundary. Finally, decide once whether the project uses classnames or clsx and enforce it with a lint rule, because both being present is the single most common way this dependency doubles.

Patterns

Every input shape in one placebasic-usage

import classNames from 'classnames';

classNames('foo', 'bar');                      // 'foo bar'
classNames('foo', { bar: true });              // 'foo bar'
classNames({ 'foo-bar': false });              // ''
classNames({ foo: true, bar: true });          // 'foo bar'
classNames('foo', { bar: true, duck: false }); // 'foo bar'
classNames(null, false, 'bar', undefined, 0, { baz: null }, ''); // 'bar'
classNames('a', ['b', { c: true, d: false }]); // 'a b c'

A bare string is shorthand for { thatString: true }. Every falsy value is dropped, including 0, which matters if you ever compute a class name from a numeric index: classNames(items[i]) silently produces an empty string when the value is 0. Arrays flatten recursively with no depth limit.

Replace ternary concatenation in a componentconditional-react-classname

import classNames from 'classnames';

function Button({ variant, isPressed, isLoading, children }) {
  const cls = classNames('btn', `btn--${variant}`, {
    'btn--pressed': isPressed,
    'btn--loading': isLoading,
    'btn--idle': !isPressed && !isLoading,
  });

  return <button className={cls} disabled={isLoading}>{children}</button>;
}

The object form keeps the condition next to the class it controls, which is the actual win over string building. Watch mutually exclusive states: nothing stops both btn--pressed and btn--idle appearing if your conditions overlap, and CSS specificity then decides the outcome rather than your intent.

Let callers extend a component's classesmerge-incoming-classname

function Card({ className, elevated, children }) {
  return (
    <div className={classNames('card', { 'card--elevated': elevated }, className)}>
      {children}
    </div>
  );
}

<Card className="mt-4 col-span-2" elevated />
// 'card card--elevated mt-4 col-span-2'

Put the incoming className last so a consumer's classes end up at the end of the string, and undefined is dropped automatically so there is no need for a default prop. Note that string position does not determine which rule wins in CSS; it only matters for tools that resolve conflicts, such as tailwind-merge.

Build keys from valuesdynamic-class-names

const buttonType = 'primary';
classNames({ [`btn-${buttonType}`]: true });   // 'btn-primary'

// usually clearer as a plain string argument
classNames('btn', `btn-${buttonType}`, size && `btn-${size}`);

// map form, for a fixed set
const TONE = { ok: 'text-green-600', warn: 'text-amber-600', bad: 'text-red-600' };
classNames('badge', TONE[tone]);

Computed keys work but the object wrapper buys you nothing when the value is always true, so pass the template literal directly. The lookup-map form is safer with Tailwind because a class name assembled from fragments at runtime is not visible to Tailwind's content scanner and gets purged from the stylesheet.

Use the dedupe build when a later value must cancel an earlier onededupe-conflicting-classes

import classNames from 'classnames/dedupe';

classNames('foo', 'foo', 'bar');          // 'foo bar'
classNames('foo', { foo: false, bar: true }); // 'bar'

// the default build keeps both
// require('classnames')('foo', { foo: false }); // 'foo'

This is the only variant where a later argument can remove a class an earlier one added, which is occasionally exactly what you need for a theming layer. The README states it is roughly 5x slower than the default, so import it at the specific call sites that need the behavior rather than aliasing it project-wide.

Resolve short names through a styles objectbind-for-css-modules

import classNames from 'classnames/bind';
import styles from './submit-button.module.css';

const cx = classNames.bind(styles);

export default function SubmitButton({ busy, invalid }) {
  const className = cx('base', { inProgress: busy, error: invalid });
  return <button className={className}>{busy ? 'Processing...' : 'Submit'}</button>;
}

cx looks each name up in the bound object and falls back to the literal string when there is no match, which is handy for mixing module classes with global utilities but also means a typo produces a silently wrong class instead of an error. The README itself points out that in an ES2015 codebase, styles.base with computed keys is often clearer than the binding indirection.

Types are bundled, including for the subpathstypescript-usage

import classNames, { type Argument } from 'classnames';
// bind and dedupe have their own definitions:
// import cx from 'classnames/bind';

function cell(...extra: Argument[]) {
  return classNames('cell', extra);
}

cell('is-active', { 'is-selected': false });

index.d.ts ships in the package, so installing @types/classnames on top gives you a stale duplicate declaration. Because the module is CommonJS, a default import needs esModuleInterop or allowSyntheticDefaultImports in tsconfig; without either you get an error telling you the module has no default export.

Get the import form right for your runtimeimport-in-esm-and-cjs

// bundlers (Vite, webpack, Next.js) and TypeScript with esModuleInterop
import classNames from 'classnames';

// native Node ESM: default import only
import classNames from 'classnames';        // works
// import { default as cn } from 'classnames'; // also works
// import * as cn from 'classnames';           // cn is the namespace, not the function

// CommonJS
const classNames = require('classnames');

There is no ESM build, only CommonJS, so Node's interop synthesises the default export for you. The namespace import is the one that surprises people: you get a module namespace object that is not callable. Vite will also list classnames in its dependency pre-bundling step for this reason.

Know why p-2 and p-4 both survivehandle-tailwind-conflicts

classNames('p-2', isWide && 'p-4');
// -> 'p-2 p-4'   both present; the stylesheet decides, not you

// the usual fix, note that the standard helper pairs twMerge with clsx
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';

export const cn = (...args) => twMerge(clsx(args));
cn('p-2', isWide && 'p-4');   // -> 'p-4'

classnames is a string joiner and has no idea that p-2 and p-4 target the same property. tailwind-merge is the piece that understands Tailwind's utility groups. You can wrap classNames instead of clsx if you prefer, but every published version of the cn helper uses clsx, so matching it avoids confusing the next person.

Get autocomplete and class sorting inside the callenable-editor-tooling

// .vscode/settings.json
{
  "tailwindCSS.experimental.classRegex": [
    ["classNames\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
  ]
}

// prettier.config.js
module.exports = {
  plugins: ['prettier-plugin-tailwindcss'],
  tailwindFunctions: ['classNames', 'cx', 'cn'],
};

Without this, Tailwind CSS IntelliSense stops offering completions and warnings the moment a class moves inside a function call, and prettier-plugin-tailwindcss leaves those strings unsorted while sorting every plain className attribute, which produces a confusing mixed diff. Add every alias your codebase actually uses, including cx from the bind build.

Keep computed classes visible to the content scanneravoid-runtime-string-building

// bad: Tailwind and most CSS tooling never see these strings
classNames(`text-${color}-500`, `w-${width}`);

// good: full class names appear literally in the source
const TEXT = { red: 'text-red-500', blue: 'text-blue-500' };
const WIDTH = { sm: 'w-24', lg: 'w-64' };
classNames(TEXT[color], WIDTH[width]);

This is not a classnames bug, but it is where its flexibility bites: it happily returns text-red-500 even when that class was purged from the stylesheet because no scanner ever saw the literal. The symptom is styles that work in development and vanish in a production build. Lookup maps keep the literals in the file.

Swap the import when you decide to movemigrate-to-clsx

// most call sites are identical
-import classNames from 'classnames';
+import clsx from 'clsx';

-classNames('btn', { active }, extra, ['a', ['b']]);
+clsx('btn', { active }, extra, ['a', ['b']]);

// what does not port directly:
//   classnames/bind    -> write a 3-line wrapper over your styles object
//   classnames/dedupe  -> no clsx equivalent; keep classnames in those files

Strings, objects, arrays, nesting, and falsy handling all behave the same, so a find and replace covers the vast majority of a codebase. Do it in one commit and add a lint rule banning the old import, otherwise you end up shipping both, which costs more than either one saved.

Alternatives

PackageRegistryPick it when
clsxnpmYou are starting fresh: same API, smaller output, and a real ESM build, which makes it the default choice for new code.
tailwind-mergenpmYou are on Tailwind and need later utility classes to actually override earlier ones instead of both landing in the string.
class-variance-authoritynpmYour conditional logic has grown into variant tables (size, tone, state) and you want typed variants rather than nested objects at each call site.
classcatnpmYou want the smallest possible implementation and only ever pass strings, arrays, and objects with no nesting depth to speak of.