@codefast/tailwind-variants
v0.9.0
Published
Tailwind CSS variants utilities with enhanced functionality and advanced type safety
Maintainers
Readme
@codefast/tailwind-variants
A type-safe variant styling API for Tailwind CSS — a faster drop-in replacement for tailwind-variants, for anyone
building components whose classes depend on props.
Overview
@codefast/tailwind-variants turns one configuration into a typed function that returns the right Tailwind classes for
a set of props. Describe your base, variants, and slots once; call the function with props; get back a merged
class string. It's a drop-in, faster replacement for tailwind-variants, with the same configuration shape.
tv() costs next to nothing. The first call resolves straight from the configuration, the second compiles it into a
plan, and from then on resolving a component is string work — with a repeated selection answered from a cache.
- One configuration, one typed function. Describe
base,variants,compoundVariants,slots, anddefaultVariantsonce; get back a function whose props are inferred from it. - Conflicts settled by
tailwind-merge. A caller'sclassNamewins over the configuration, and an unknown variant value is a type error. - Compiled once, when it pays.
tv()only wraps the configuration; the first render reads it directly and the second compiles it into a plan, so a component rendered once costs no more than that render, and every render after is string work with repeated selections answered from a cache. - Drop-in, with no runtime dependencies of its own. Same configuration shape as
tailwind-variants;tailwind-mergeis a peer, so you get one copy at the version you chose.
Installation
pnpm add @codefast/tailwind-variants tailwind-merge
# npm install @codefast/tailwind-variants tailwind-merge
# yarn add @codefast/tailwind-variants tailwind-merge@codefast/tailwind-variants ships ESM only. tailwind-merge is a peer dependency (>=3.0.0), and the package runs on
Node.js 24 or later and in Chrome and Edge 136, Firefox 136, or Safari 18.4 or later
(support policy). It requires TypeScript 7 or later for its types. It's published on 0.x
and versioned on its own track: breaking changes ship as minor versions, so pin the minor version when you need
stability.
Quick start
import { tv } from "@codefast/tailwind-variants";
const button = tv({
base: "inline-flex items-center justify-center rounded-md font-medium",
variants: {
variant: { primary: "bg-primary text-primary-foreground", outline: "border border-input bg-background" },
size: { sm: "h-9 px-3 text-sm", md: "h-10 px-4", lg: "h-11 px-8" },
},
defaultVariants: { variant: "primary", size: "md" },
});
button();
// => "inline-flex items-center justify-center rounded-md font-medium bg-primary text-primary-foreground h-10 px-4"
button({ variant: "outline", size: "lg" });
// => "... border border-input bg-background h-11 px-8"
button({ size: "sm", className: "w-full" });
// => "... bg-primary text-primary-foreground h-9 px-3 text-sm w-full"Every call accepts className or class for ad-hoc additions. They are appended last, so they win any Tailwind
conflict with the configuration; when both are passed, className is used.
Variants
Boolean variants
A variant group with "true" / "false" keys accepts real booleans. When such a group has no entry in
defaultVariants, it defaults to false:
const toggle = tv({
base: "rounded-md text-sm",
variants: {
pressed: { true: "bg-accent", false: "bg-transparent" },
disabled: { true: "pointer-events-none opacity-50" },
},
});
toggle(); // => "rounded-md text-sm bg-transparent"
toggle({ pressed: true, disabled: true });
// => "rounded-md text-sm bg-accent pointer-events-none opacity-50"Compound variants
compoundVariants apply extra classes only when all listed conditions match. A condition value may be an array,
matching any of its entries:
const alert = tv({
base: "rounded-lg border px-4 py-3",
variants: {
variant: { default: "bg-background", destructive: "text-destructive" },
size: { sm: "text-sm", md: "text-base" },
},
compoundVariants: [{ variant: "destructive", size: ["sm", "md"], className: "font-semibold" }],
defaultVariants: { variant: "default", size: "md" },
});
alert({ variant: "destructive" });
// => "rounded-lg border px-4 py-3 text-destructive text-base font-semibold"Slots
Add slots to style a multi-part component. The variant function then returns an object of per-slot functions. A
variant value can be a plain class string (applied to the base slot) or a slot-to-class map:
const card = tv({
slots: {
root: "rounded-xl border bg-card shadow-sm",
header: "flex flex-col gap-1.5 p-6",
content: "p-6 pt-0",
},
variants: { inset: { true: { content: "px-3" } } },
});
const styles = card({ inset: true });
styles.root(); // => "rounded-xl border bg-card shadow-sm"
styles.content(); // => "p-6 pt-0 px-3"
styles.content({ className: "pb-0" }); // => "p-6 pt-0 px-3 pb-0" — merged per slotEach slot function also accepts variant props of its own, which override the values given at the top-level call for that
slot only. In a slot configuration, a compound variant's className may be a slot map too.
Compound slots
compoundSlots target several slots at once, optionally gated on variant conditions:
const pagination = tv({
slots: { item: "flex flex-wrap", prev: "", next: "" },
variants: { size: { sm: "", md: "" } },
compoundSlots: [
{ slots: ["item", "prev", "next"], className: "size-9 rounded-md" },
{ slots: ["item", "prev", "next"], size: "sm", className: "size-7 text-xs" },
],
defaultVariants: { size: "md" },
});
pagination({ size: "sm" }).item();
// => "flex flex-wrap rounded-md size-7 text-xs"Extending
extend inherits another variant function's configuration. Base classes concatenate; variants, slots, defaults, and
compound definitions merge; the resulting types reflect the union; and a chain of any depth collapses into one
configuration when tv() runs:
const baseButton = tv({
base: "inline-flex items-center rounded-md",
variants: { size: { sm: "h-9 px-3", md: "h-10 px-4" } },
defaultVariants: { size: "md" },
});
const iconButton = tv({
extend: baseButton,
base: "aspect-square",
variants: { tone: { ghost: "hover:bg-accent", outline: "border border-input" } },
defaultVariants: { tone: "ghost" },
});
iconButton();
// => "inline-flex items-center rounded-md aspect-square h-10 px-4 hover:bg-accent"
iconButton({ tone: "outline", size: "sm" });
// => "inline-flex items-center rounded-md aspect-square h-9 px-3 border border-input"Options and createTV
tv(config, options) takes an optional second argument:
twMerge— setfalseto keep every declared class instead of resolving conflicts (defaulttrue).twMergeConfig— atailwind-mergeConfigExtensionfor custom class groups.cacheResolutions— setfalseto resolve every call from scratch (defaulttrue).
A variant function remembers what each selection resolved to, because a list renders the same few selections many times and both the plan walk and the merge are pure functions of the selection. Two consequences are worth knowing:
- Once its plan is compiled — from the second call on — a slot component called with a selection it has already seen gets back the same object of slot functions. That is stable enough for a React dependency array; it also means the object is shared, so do not mutate it.
- The store is bounded and keyed by the selection, so a variant whose values are effectively unique per call (an id, a
timestamp, a fresh object) fills it with entries nothing reads again.
cacheResolutions: falseis the escape hatch for that component.
createTV(options) bakes those options into a shared factory and returns { tv, cn }:
import { createTV } from "@codefast/tailwind-variants";
const { tv, cn } = createTV({
twMergeConfig: { extend: { classGroups: { "font-size": [{ text: ["huge"] }] } } },
});
cn("text-base", "text-huge"); // => "text-huge" — the custom group is understoodOptions passed locally to tv(config, options) override the factory's globals. Every variant function also exposes
config, its fully merged configuration (what extend reads), and variantKeys, the variant names in declaration
order with an extended function's first — the same list upstream exposes, for splitting variant props from the rest by
hand.
Class utilities
import { cn, cx } from "@codefast/tailwind-variants";
cn("px-4 py-2", "px-6"); // => "py-2 px-6" — joined, then tailwind-merge
cx("px-4 py-2", "px-6"); // => "px-4 py-2 px-6" — joined only, no merging
cn("base", isActive && "text-primary", { hidden: false }); // conditional values, the shapes clsx acceptsTypeScript
VariantProps extracts the variant props of a variant function (with class / className stripped) for reuse in
component props:
import { tv } from "@codefast/tailwind-variants";
import type { VariantProps } from "@codefast/tailwind-variants";
import type { ComponentProps } from "react";
const button = tv({
base: "rounded px-4 py-2",
variants: { variant: { primary: "bg-primary", outline: "border" } },
});
interface ButtonProps extends ComponentProps<"button">, VariantProps<typeof button> {}
function Button({ variant, className, ...props }: ButtonProps) {
return <button className={button({ variant, className })} {...props} />;
}Variant values, slot names, and compound conditions are all inferred — passing an unknown variant value or accessing a
slot that does not exist is a type error. ClassValue is the type of anything accepted where classes are expected
(strings, numbers, nested arrays, condition objects). Configuration and option types (VariantConfig,
SlotVariantConfig, ExtendedVariantConfig, TailwindVariantsOptions, VariantResolver, and friends) are exported
for library authors.
Migrating from tailwind-variants
The configuration shape is the same — base, variants, slots, defaultVariants, compoundVariants,
compoundSlots, extend, plus the twMerge / twMergeConfig options — so for most codebases the migration is the
import:
- import { tv, cn, cx } from "tailwind-variants";
+ import { tv, cn, cx } from "@codefast/tailwind-variants";Two differences to check for:
createTVreturns an object{ tv, cn }sharing the global options, rather than a baretvfunction — destructure instead of assigning directly.cnMergeand a mutabledefaultConfigare not exported; usecreateTVto configure merging.variantKeysis exposed as upstream exposes it; the other metadata upstream attaches to the function (base,variants,defaultVariants,slots,compoundVariants,compoundSlots,extend) lives underconfig.
Benchmarks
The repository maintains a benchmark suite that runs the same variant workloads —
simple, complex, slots, compound slots, extend, createTV, and extreme configurations, each with and without merging
— against the upstream tailwind-variants package and class-variance-authority in isolated subprocesses. Numbers vary
by hardware, so run it yourself rather than reading them here:
pnpm --filter @benchmark/tailwind-variants benchThe speed comes from settling things once, and only once a component has shown it will be rendered again. tv() wraps
the configuration and the first call reads it directly; the second call compiles it into a plan — variant groups,
compound conditions, and slot positions settled, every class value flattened to a string — so every resolution after is
string concatenation rather than dictionary lookups. The answer is then settled per selection, so a list rendering the
same few selections resolves each of them once. cn / cx take the same string fast path, joining directly when every
argument is already a string. The suite prices both ends: define-only-* and first-render-* for a definition and its
first render, the resolution rows for every render after.
Documentation
- Rendered docs on codefastlabs.com
- ARCHITECTURE.md — the compile-then-resolve design, which parts are load-bearing, and how to measure a change to it
- DECISIONS.md — why the package exists and the choices that fix its API
- CHANGELOG.md — release history
Contributing
Issues and pull requests are welcome. Start with the repository's contributing guide; it covers the toolchain, the test layout, and the release flow.
License
Released under the MIT License.
