Variants without a dependency
The shadcn/ui model builds component variants with class-variance-authority and joins them with cn().
The shadcn/ui model builds component variants with class-variance-authority and joins them with cn(). Neither
package is needed: the helper below has the same shape (base, variants, defaultVariants, compoundVariants) in
about 40 lines, and its result is a string that class accepts.
src/lib/variants.tsts
export type ClassValue =
| string
| number
| bigint
| null
| undefined
| false
| readonly ClassValue[]
| { readonly [name: string]: unknown };
/** Joins class values: strings and numbers are kept, falsy values dropped, objects contribute truthy keys. */
export function cn(...inputs: readonly ClassValue[]): string {
const out: string[] = [];
const add = (value: ClassValue): void => {
if (value === null || value === undefined || value === false || value === "") return;
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") {
out.push(String(value));
} else if (Array.isArray(value)) {
for (const item of value as readonly ClassValue[]) add(item);
} else {
for (const [name, enabled] of Object.entries(value)) if (enabled) out.push(name);
}
};
for (const input of inputs) add(input);
return out.join(" ");
}
type VariantTable = Record<string, Record<string, string>>;
type Choice<V extends VariantTable> = { readonly [K in keyof V]?: Extract<keyof V[K], string> | undefined };
export interface VariantsConfig<V extends VariantTable> {
readonly variants: V;
readonly defaultVariants?: Choice<V>;
readonly compoundVariants?: ReadonlyArray<Choice<V> & { readonly class: string }>;
}
/** The selection a caller passes: one key per variant, plus extra classes. */
export type VariantProps<V extends VariantTable> = Choice<V> & { readonly class?: ClassValue };
/** A `cva`-shaped helper: returns a function from a selection to a class string. */
export function variants<V extends VariantTable>(
base: ClassValue,
config: VariantsConfig<V>,
): (selection?: VariantProps<V>) => string {
const table: VariantTable = config.variants;
return (selection = {} as VariantProps<V>) => {
const asked: Record<string, unknown> = selection;
const defaults: Record<string, unknown> = config.defaultVariants ?? {};
const chosen: Record<string, string | undefined> = {};
const parts: ClassValue[] = [base];
for (const name of Object.keys(table)) {
const key = (asked[name] ?? defaults[name]) as string | undefined;
chosen[name] = key;
if (key !== undefined) parts.push(table[name]?.[key]);
}
for (const compound of config.compoundVariants ?? []) {
const rule: Record<string, unknown> = compound;
const matches = Object.keys(table).every((name) => rule[name] === undefined || rule[name] === chosen[name]);
if (matches) parts.push(compound.class);
}
parts.push(selection.class);
return cn(parts);
};
}
src/button.tsxtsx
import { variants } from "./lib/variants";
export const button = variants(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors disabled:opacity-50",
{
variants: {
variant: {
default: "bg-slate-900 text-white hover:bg-slate-900/90",
destructive: "bg-red-600 text-white hover:bg-red-600/90",
outline: "border border-slate-200 bg-white hover:bg-slate-100",
},
size: { default: "h-9 px-4", sm: "h-8 px-3 text-xs", lg: "h-10 px-8" },
},
defaultVariants: { variant: "default", size: "default" },
compoundVariants: [{ variant: "outline", size: "sm", class: "border-dashed" }],
},
);
export type ButtonProps = Parameters<typeof button>[0] & { children?: string };
export function Button(props: ButtonProps) {
return (
<button
type="button"
class={button({
variant: props.variant,
size: props.size,
...(props.class === undefined ? {} : { class: props.class }),
})}
>
{props.children}
</button>
);
}
export function Buttons() {
return (
<>
<Button>Save</Button>
<Button variant="destructive" size="lg">
Remove
</Button>
</>
);
}