tailwind2-6 Exercise 2: A cva Button, Its Merge Trap and Its Types ==================================================================== Setup: npm install class-variance-authority clsx tailwind-merge ; for the type check also npm install typescript and a tsconfig.json with "strict": true. Save as cva.mjs and run node cva.mjs --- cva.mjs --- import { cva } from 'class-variance-authority'; import { twMerge } from 'tailwind-merge'; import { clsx } from 'clsx'; const button = cva('inline-flex items-center rounded-md font-medium', { variants: { intent: { primary: 'bg-blue-600 text-white hover:bg-blue-700', danger: 'bg-red-600 text-white' }, size: { sm: 'px-2 py-1 text-sm', md: 'px-4 py-2 text-base' }, }, compoundVariants: [{ intent: 'danger', size: 'sm', class: 'uppercase' }], defaultVariants: { intent: 'primary', size: 'md' }, }); const cn = (...a) => twMerge(clsx(a)); const s=(l,v)=>console.log(l.padEnd(44),v); s('button()', button()); s('button({intent:"danger",size:"sm"})', button({ intent: 'danger', size: 'sm' })); s('button({size:"lg"}) unknown value', button({ size: 'lg' })); s('button({intent:undefined})', button({ intent: undefined })); s('button({class:"px-8"}) no merge', button({ class: 'px-8' })); s('twMerge(button({class:"px-8"}))', twMerge(button({ class: 'px-8' }))); s('cn(button(), "bg-green-600")', cn(button(), 'bg-green-600')); Output: button() inline-flex items-center rounded-md font-medium bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 text-base button({intent:"danger",size:"sm"}) inline-flex items-center rounded-md font-medium bg-red-600 text-white px-2 py-1 text-sm uppercase button({size:"lg"}) unknown value inline-flex items-center rounded-md font-medium bg-blue-600 text-white hover:bg-blue-700 button({intent:undefined}) inline-flex items-center rounded-md font-medium bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 text-base button({class:"px-8"}) no merge inline-flex items-center rounded-md font-medium bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 text-base px-8 twMerge(button({class:"px-8"})) inline-flex items-center rounded-md font-medium bg-blue-600 text-white hover:bg-blue-700 py-2 text-base px-8 cn(button(), "bg-green-600") inline-flex items-center rounded-md font-medium text-white hover:bg-blue-700 px-4 py-2 text-base bg-green-600 Type check (TypeScript 7.0.2, strict). src/t.ts: import { cva, type VariantProps } from "class-variance-authority"; const button = cva("btn", { variants: { size: { sm: "px-2", md: "px-4" } }, defaultVariants: { size: "md" } }); type Props = VariantProps; export const ok: Props = { size: "sm" }; export const bad: Props = { size: "lg" }; npx tsc --noEmit -p . src/t.ts(5,29): error TS2322: Type '"lg"' is not assignable to type '"md" | "sm" | null | undefined'. WHY THIS WORKS AS AN ANSWER --------------------------- Line 1 shows the defaults filling in intent=primary and size=md. Line 2 shows both groups chosen and the compound variant adding "uppercase" only for danger+sm. Line 3 is the quiet failure: size "lg" does not exist, so no size classes are added and nothing is reported at run time; the TypeScript check is what catches it (the last part). Line 5 shows cva does not merge: px-4 and px-8 are both present, and the rendered padding then depends on the order of rules in the CSS, not on the string. twMerge fixes that (px-4 is gone). The last line is the trap: bg-green-600 correctly replaced bg-blue-600, but hover:bg-blue-700 is still in the string, so the button would still go blue on hover. A caller overriding a background must override the hover: class as well, or the component should expose a variant instead of a free-form class. NOTE: these calls pass the key class . The shadcn Button in the chapter passes className to cva the same way and the extra classes were applied; I did not run className in this script.