Skip to content

Class names

Merges class names and resolves Tailwind conflicts.

Status
Stable
Level
Utility
Category
Utilities
Adoption
Not used yet
import { cn } from "@oration/canon/lib/utils";
packages/canon/src/lib/utils.ts
Callcn packageCanon cn
cn("text-13", "text-foreground")text-foregroundNorthwind Freighttext-13 text-foregroundNorthwind Freight
cn("text-sm", "text-13")text-sm text-13Northwind Freighttext-13Northwind Freight
cn("text-13 text-muted-foreground", "text-foreground")text-foregroundNorthwind Freighttext-13 text-foregroundNorthwind Freight
cn("text-13", "text-sm")text-13 text-smNorthwind Freighttext-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 className in 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 className last 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 with buttonVariants, and pass the result through cn. 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 cn from the cn package in apps/web. Use @oration/canon/lib/utils.

The Thirteen-Fourteen Rule

Dense UI is 13px, reading text is 14px, meta is 12px. 11px is for footnotes only. 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 14px
text-[13px]: 13px
import { 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") is py-1 px-3.
  • Falsy values are skipped, arrays are flattened and object keys are kept when their value is true.
  • Canon's cn adds 13 and 2xs to the font-size group, so text-13 conflicts with text-sm (one size survives) and coexists with text-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-2xs was already treated as a size by the cn package (0.4.0); registering it is defensive. Only text-13 was misread.

Do and don't#

Northwind Freight
Do. Import cn from @oration/canon/lib/utils in app and docs code.
Northwind Freight
Don't. Import it from the cn package, where text-13 beside a text color silently drops the size.
Do. Pass text-[13px] when shrinking a packages/canon component's text-sm through its className.
Don't. Pass text-13 to a packages/canon component. Its internal merge keeps text-sm and the label stays 14px.
Do. Put the caller's className last: cn(defaults, className).
Don't. Put it first: 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-13 usually fails safe (text stays 14px), but a dropped color class can leave text at a contrast nobody chose.

Design tokens#

Design tokens
TokenUsed 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.

Props of cn
PropTypeDefaultDescription
...inputsClassValue[]No defaultStrings, numbers, falsy values, arrays and { [className]: boolean } objects.

Returns

Props of Returns
PropTypeDefaultDescription
classNamestringNo defaultThe 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.