cn
Combines classNames with Tailwind v4's class conflict resolution.
Every CoreUI component passes its classes through cn, which is what lets a className you pass in override the component's own styles instead of racing them in the cascade.
Installation
Install the following dependencies:
npm install clsx tailwind-mergeCreate a utils/cn.ts file and paste the following code into it.
import clsx, { type ClassValue } from 'clsx';import { extendTailwindMerge } from 'tailwind-merge';export { type ClassValue } from 'clsx';// CoreUI Typography Classes - Dynamic Pattern Matchingconst typographyConfig = { title: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], label: ['xl', 'lg', 'md', 'sm', 'xs', '2xs'], paragraph: ['xl', 'lg', 'md', 'sm', 'xs'], subheading: ['md', 'sm', 'xs', '2xs'], doc: ['label', 'paragraph'], ln: [ 'title-h1', 'title-h2', 'title-h3', 'title-h4', 'title-h5', 'title-h6', 'label-lg', 'label-md', 'label-sm', 'label-xs', 'paragraph-lg', 'paragraph-md', 'paragraph-sm', 'paragraph-xs', 'subheading-xs', 'docs-sm', 'special-13-desc', ],};const typographyPatterns = Object.entries(typographyConfig).flatMap( ([category, sizes]) => sizes.map((size) => `${category}-${size}`),);export const twMergeConfig = { extend: { classGroups: { 'font-size': [ { text: typographyPatterns, }, ], }, },};const customTwMerge = extendTailwindMerge(twMergeConfig);/** * Utilizes `clsx` with `tailwind-merge`, use in cases of possible class conflicts. */export function cn(...classes: ClassValue[]) { return customTwMerge(clsx(...classes));}IntelliSense setup (optional)
For class name completion inside cn and tv, add this to your .vscode/settings.json:
{
"tailwindCSS.experimental.classRegex": [
["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"]
]
}Prettier setup (optional)
If you use prettier-plugin-tailwindcss, list cn so its classes get sorted too:
const config = {
plugins: ['prettier-plugin-tailwindcss'],
tailwindFunctions: ['cn'],
};
export default config;Why the custom merge config
tailwind-merge resolves conflicts by knowing which classes belong to the same group — it drops px-2 when it sees a later px-4. It can't know that about CoreUI's typography scale, because text-label-sm and text-paragraph-md are custom utilities, not stock Tailwind ones.
Without the config, both would survive a merge and the winner would come down to source order. twMergeConfig registers every title-*, label-*, paragraph-*, subheading-* and doc-* name as a single font-size group, so the last one wins as you'd expect:
cn('text-label-sm', 'text-paragraph-md'); // → 'text-paragraph-md'twMergeConfig is exported rather than kept private because tv needs the same group definitions.
The ln-* entries are documentation-only
The ln category in the config covers the tokens this documentation site uses
for its own prose. They're harmless to keep, but nothing in
components/ui references them — drop that block if you want the file to
carry only what your app uses.
Examples
cn takes anything clsx accepts, so conditional objects and arrays work alongside plain strings:
import { cn } from '@/utils/cn';
function MyComponent({
className,
isActive,
...rest
}: React.HTMLAttributes<HTMLDivElement> & {
isActive?: boolean;
}) {
return (
<div
className={cn(
'size-3 bg-bg-white-0 text-text-strong-950',
{
'bg-bg-strong-950 text-text-white-0': isActive,
},
className,
)}
{...rest}
/>
);
}Passing className last is the important part — that's the position that lets a caller override the defaults.