Class names
Merges class names and resolves Tailwind conflicts.
| Call | cn package | Canon cn |
|---|---|---|
cn("text-13", "text-foreground") | text-foregroundNorthwind Freight | text-13 text-foregroundNorthwind Freight |
cn("text-sm", "text-13") | text-sm text-13Northwind Freight | text-13Northwind Freight |
cn("text-13 text-muted-foreground", "text-foreground") | text-foregroundNorthwind Freight | text-13 text-foregroundNorthwind Freight |
cn("text-13", "text-sm") | text-13 text-smNorthwind Freight | text-smNorthwind Freight |
import { cn } from "@oration/canon/lib/utils";export function BeforeAfter() { const cases = [ { inputs: ["text-13", "text-foreground"], before: "text-foreground" }, { inputs: ["text-sm", "text-13"], before: "text-sm text-13" }, { inputs: ["text-13 text-muted-foreground", "text-foreground"], before: "text-foreground", }, { inputs: ["text-13", "text-sm"], before: "text-13 text-sm" }, ]; return ( <div className="w-full overflow-x-auto"> <table className="w-full min-w-[38rem] border-collapse text-left text-base"> <thead> <tr className="text-xs text-muted-foreground"> <th scope="col" className="pb-2 font-medium"> Call </th> <th scope="col" className="pb-2 font-medium"> cn package </th> <th scope="col" className="pb-2 font-medium"> Canon cn </th> </tr> </thead> <tbody> {cases.map((item) => { const after = cn(...item.inputs); return ( <tr key={item.inputs.join("|")} className="border-t border-border align-top" > <td className="py-2.5 pr-4"> <code className="font-mono text-xs text-foreground"> cn( {item.inputs .map((input) => `"${input}"`) .join(", ")} ) </code> </td> <td className="py-2.5 pr-4"> <code className="block font-mono text-xs text-muted-foreground"> {item.before} </code> <span className={item.before}> Northwind Freight </span> </td> <td className="py-2.5"> <code className="block font-mono text-xs text-muted-foreground"> {after} </code> <span className={after}> Northwind Freight </span> </td> </tr> ); })} </tbody> </table> </div> );}Usage#
cn joins class names and resolves Tailwind conflicts, so a component's defaults give way to the className a caller passes. The Canon version in @oration/canon/lib/utils is built with createCn and registers text-13 and text-2xs as font sizes. Without that, the merge engine reads text-13 as a text color: cn("text-13", "text-foreground") dropped the size, and cn("text-sm", "text-13") kept both. Components inside packages/canon still import the plain cn package, so they still have the bug.
When to use
- Composing
classNamein every component and demo:cn("base classes", condition && "state class", className). - Conditional classes, with falsy values, arrays and
{ class: boolean }objects. - Letting a caller override a default: put
classNamelast and the later class wins the conflict.
When not to use
- A matrix of variants and sizes. Define it once with
cva, as Button does withbuttonVariants, and pass the result throughcn. Use Button - Values that change at runtime, such as a width or a ratio. Set a CSS variable or
style, as AspectRatio does with--ratio. Use Aspect ratio - Toggling one class from a component's own state. A data attribute and a variant (
data-active:font-medium) usually reads better. - Importing
cnfrom thecnpackage in apps/web. Use@oration/canon/lib/utils.
The Thirteen-Fourteen Rule
text-13 is the most used size in the suite, so a merge that drops it breaks density everywhere it happens.Examples#
Overriding a packages/canon component
Button merges with the plain cn package, so text-13 next to a text color is dropped and the label stays at Button's text-sm. text-[13px] is read as a size by both and wins.
text-13: stays 14pxtext-[13px]: 13pximport { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";export function ComponentOverride() { return ( <div className="flex flex-wrap items-start gap-8"> <div className="flex flex-col items-start gap-1.5"> <Button type="button" variant="ghost" className="text-13 text-muted-foreground" onClick={() => toast.add({ title: "Remittance downloaded" }) } > Download remittance </Button> <code className="font-mono text-xs text-muted-foreground"> text-13: stays 14px </code> </div> <div className="flex flex-col items-start gap-1.5"> <Button type="button" variant="ghost" className="text-[13px] text-muted-foreground" onClick={() => toast.add({ title: "Remittance downloaded" }) } > Download remittance </Button> <code className="font-mono text-xs text-muted-foreground"> text-[13px]: 13px </code> </div> </div> );}Conditional classes
Ternaries, && and objects all work: falsy values drop out and object keys stay when their value is true, so one flag drives the tint, weight and text color.
import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Conditional() { const [selected, setSelected] = React.useState("INV-20944"); const invoices = [ { id: "INV-20931", supplier: "Northwind Freight", overdue: false }, { id: "INV-20944", supplier: "Halcyon Packaging", overdue: true }, { id: "INV-20952", supplier: "Orchard Street Supply", overdue: false }, ]; return ( <ul aria-label="Invoices" className="flex w-full max-w-sm flex-col gap-px" > {invoices.map((invoice) => { const active = invoice.id === selected; return ( <li key={invoice.id}> <button type="button" aria-pressed={active} onClick={() => setSelected(invoice.id)} className={cn( "flex h-9 w-full items-center justify-between gap-3 rounded-lg px-2.5 text-left text-13 outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", active ? "bg-primary/[0.06] font-medium" : "hover:bg-muted", { "text-foreground": active, "text-muted-foreground": !active, }, )} > <span>{invoice.supplier}</span> {invoice.overdue ? ( <span className="text-xs font-normal text-destructive"> Overdue </span> ) : null} </button> </li> ); })} </ul> );}Behavior#
- Later classes win conflicts in the same group:
cn("px-2 py-1", "px-3")ispy-1 px-3. - Falsy values are skipped, arrays are flattened and object keys are kept when their value is true.
- Canon's
cnadds13and2xsto thefont-sizegroup, sotext-13conflicts withtext-sm(one size survives) and coexists withtext-foreground(a size and a color). - Arbitrary sizes such as
text-[13px]are recognized as font sizes by both versions, which is why they are the safe override through packages/canon components. text-2xswas already treated as a size by thecnpackage (0.4.0); registering it is defensive. Onlytext-13was misread.
Do and don't#
cn from @oration/canon/lib/utils in app and docs code.cn package, where text-13 beside a text color silently drops the size.text-[13px] when shrinking a packages/canon component's text-sm through its className.text-13 to a packages/canon component. Its internal merge keeps text-sm and the label stays 14px.className last: cn(defaults, className).cn(className, defaults). The defaults win every conflict and overrides do nothing.Accessibility#
- A dropped class is invisible in code review. After overriding focus, outline or ring classes, check the merged result still draws a visible focus indicator.
- A dropped
text-13usually fails safe (text stays 14px), but a dropped color class can leave text at a contrast nobody chose.
Design tokens#
| Token | Used for |
|---|---|
text-13 | --text-13: 0.8125rem on a 1.25rem line |
text-2xs | --text-2xs: 0.6875rem on a 1rem line |
API reference#
cn
cn(...inputs: ClassValue[]): string, from @oration/canon/lib/utils. Built as createCn({ extend: { classGroups: { "font-size": [{ text: ["13", "2xs"] }] } } }) from cn/config.
| Prop | Type | Default | Description |
|---|---|---|---|
...inputs | ClassValue[] | No default | Strings, numbers, falsy values, arrays and { [className]: boolean } objects. |
Returns
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | No default | The joined classes with conflicts resolved, later inputs winning. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
131 files in packages/canon import cn from the cn package, not from @oration/canon/lib/utils. Through their className, text-13 text-muted-foreground keeps the component's text-sm and drops the 13px. Use text-[13px] there until they switch.
The comment in lib/utils.ts says both text-13 and text-2xs read as colors before the change. With cn 0.4.0 only text-13 did.