The shadcn helpers
shadcn/ui's lib/utils.ts exports cn, and its components use cva.
shadcn/ui's lib/utils.ts exports cn, and its components use cva. The same two helpers, without dependencies, are
in the Tailwind page; they are repeated here because each
page is its own project. class accepts strings, arrays and objects, so cn only has to join; swap its body for a
tailwind-merge call if you need conflicting utilities resolved (see the Tailwind page).
src/lib/utils.tsts
export type ClassValue =
| string
| number
| bigint
| null
| undefined
| false
| readonly ClassValue[]
| { readonly [name: string]: unknown };
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 }>;
}
export type VariantProps<V extends VariantTable> = Choice<V> & { readonly class?: ClassValue };
/** The shape of `cva(base, { variants, defaultVariants, compoundVariants })`. */
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);
};
}
A small inline icon replaces lucide-react (any SVG component works the same way):
src/ui/icons.tsxtsx
export function CheckIcon(props: { class?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={props.class} aria-hidden="true">
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
export function XIcon(props: { class?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={props.class} aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}