Migration
Move an existing app to Clinia UI.
This page showcases how to migrate an app that already runs shadcn to Clinia UI's version: the registry, the style, the theme, the components and the icons. An app without shadcn starts with its own components.json.
Don't run shadcn init again. It would overwrite your components.json.
In an app with a src/ folder, the CLI writes every file item under src/ too: eslint.clinia.mjs, icons.manifest.ts, the icon scripts, and the SVGs from brand-logo and infinity-band. Move anything that lands in src/public/ to public/, where Next.js serves it, and import eslint.clinia.mjs from where it lands.
To have an agent do the migration, use the prompt in Using AI tools. It follows these steps and stops for your approval.
The commands use pnpm. In an npm repository, run npx shadcn@latest add where a step says pnpm dlx shadcn@latest add, and npm install --package-lock-only where it says pnpm install --lockfile-only. After each shadcn add, check that the lockfile is still your package manager's own: no pnpm-lock.yaml beside a package-lock.json.
An app without shadcn
An app that doesn't run shadcn yet needs a components.json before step 1. Write it by hand rather than running shadcn init, which rewrites globals.css and adds files that Clinia's items replace. For an app with a src/ folder:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-luma",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@clinia": {
"url": "https://ui.clinia.dev/r/{style}/{name}.json"
}
}
}styleis the surface:base-lumafor a product app,base-serafor a marketing site (step 2).tailwind.cssis the file that imports Tailwind. The theme replaces it in step 3. Without asrc/folder, it'sapp/globals.css.- The aliases need
"@/*"in thepathsoftsconfig.json, pointing to./src/*, or to./*without asrc/folder.
The file already holds steps 1 and 2: continue from step 3.
1. Add the Clinia registry
Add @clinia to the registries block of your components.json:
{
"registries": {
"@clinia": {
"url": "https://ui.clinia.dev/r/{style}/{name}.json"
}
}
}2. Set the shadcn style
Set "style" in components.json. It decides which version of each @clinia component you get, Luma or Sera, and the shape of anything you add from upstream.
Product app: shadcn's Luma style.
{
"style": "base-luma"
}Marketing site: shadcn's Sera style.
{
"style": "base-sera"
}Leave iconLibrary as it is. Clinia's components import their icons from components/ui/icons/, so the value changes nothing.
3. Install the theme
Product app:
pnpm dlx shadcn@latest add @clinia/themeMarketing site:
pnpm dlx shadcn@latest add @clinia/theme-marketingThe theme replaces app/globals.css. Copy anything your app added there before you run it, and put it back after. The theme imports the font declarations itself.
From a script or an agent, set CI=1 and add --yes --overwrite, or the command waits for an answer:
CI=1 pnpm dlx shadcn@latest add @clinia/theme --yes --overwriteThe theme installs @clinia/tailwindcss 2. If your .npmrc maps @clinia to GitHub Packages, for @clinia/web-cookiebot for one, it installs from there: GitHub Packages carries every release from 2.0.1 on. It doesn't carry 2.0.0 or the release candidates, so if your app pins one, move it to ^2.0.1.
If the app keeps its old tokens in a hand-written CSS file that also styles elements (body, h1 to h4, p) or holds classes the pages use, split it. Delete the tokens now, and keep the element rules and classes in their own file until step 11, so the type change doesn't land with the theme.
The theme sets font-synthesis: none, so the browser stops faking bold and italic as soon as it lands, before any font changes: an <em> in a face with no italic turns upright. Expect that change in this step's screenshots.
4. Host the brand fonts
@clinia/tailwindcss declares the fonts but does not include font files. Get the seven licensed .woff2 files from the brand drive, put them in public/fonts/. With access to clinia/ui, you can fetch them instead:
mkdir -p public/fonts
for f in CircularXXWeb-Book CircularXXWeb-BookItalic CircularXXWeb-Regular CircularXXWeb-Medium CircularXXWeb-MediumItalic CircularXXMonoWeb-Regular NaNSerfBText-Book; do
gh api "repos/clinia/ui/contents/apps/web/public/fonts/$f.woff2" -H "Accept: application/vnd.github.raw" > "public/fonts/$f.woff2"
doneThe fonts are licensed: don't commit them to a public repository.
| File | Family | Weight | Style |
|---|---|---|---|
CircularXXWeb-Book.woff2 | CircularXX | Book 400 | Normal |
CircularXXWeb-BookItalic.woff2 | CircularXX | Book 400 | Italic |
CircularXXWeb-Regular.woff2 | CircularXX | Regular 450 | Normal |
CircularXXWeb-Medium.woff2 | CircularXX | Medium 500 | Normal |
CircularXXWeb-MediumItalic.woff2 | CircularXX | Medium 500 | Italic |
CircularXXMonoWeb-Regular.woff2 | CircularXX Mono | Regular 400 | Normal |
NaNSerfBText-Book.woff2 | NaN Serf B | Book 400 | Normal |
The file names must match exactly: @clinia/tailwindcss/fonts points at /fonts/<file>, which the theme from step 3 imports.
Clinia UI uses these seven files and no others. Delete every other brand font file and its declaration, such as CircularXX Bold 700 or Gothia Serif, along with any font-bold or font-semibold that used them (step 8 rejects both).
An app that loads its fonts with next/font/local keeps doing so, with the same seven files. Remove the @import "@clinia/tailwindcss/fonts" line the theme adds, so no face is declared twice (and again each time you add the theme), and point the font tokens at the next/font variables after the theme imports:
:root {
--clinia-font-sans: var(--font-circular), "Inter", sans-serif;
--clinia-font-mono: var(--font-circular-mono), ui-monospace, monospace;
--clinia-font-display: var(--font-nan-serf), Georgia, serif;
}The variable names are the ones your localFont() calls set, on an element that wraps the app such as <html>. next/font keeps preloading the faces and adjusting the fallback's size.
5. Install the icon modules
Clinia UI uses Google Material Symbols in the Rounded, weight 300 flavour. The Iconography page covers the set and how to use it.
Product app:
pnpm dlx shadcn@latest add @clinia/iconsMarketing site: add the marketing icons too.
pnpm dlx shadcn@latest add @clinia/icons @clinia/marketing-iconsThis writes one module per icon under components/ui/icons/, plus icons.manifest.ts, scripts/gen-icons.mjs and scripts/codemod-icons.mjs.
6. Reinstall your components
Reinstall the components your app uses from @clinia, with --overwrite. The same command works on both surfaces: the style you set in step 2 decides whether you get the Luma or the Sera version. The icons are already wired.
pnpm dlx shadcn@latest add @clinia/button @clinia/select --overwriteThis replaces your current copies, so review the diff first if you edited them.
Then install Clinia's cn(), which knows the type scale. Without it, tailwind-merge reads text-body-s as a colour and keeps the component's own text-sm beside it, so the size never changes:
pnpm dlx shadcn@latest add @clinia/utils --overwriteIt replaces lib/utils.ts: move any other helpers your app keeps there to their own file first.
The CLI installs dependencies with its own copy of pnpm, which can be newer than the one your repository pins. After each shadcn add, run pnpm install --lockfile-only with your repository's pnpm (npm install --package-lock-only with npm) and check the lockfile diff.
A stylesheet of the app's own, imported after the theme, is unlayered, so it beats every utility in the components. A reset such as a { color: inherit } overrides text-primary-foreground and can leave a button's label dark on dark, which axe may not report. When the components arrive, move the app's element resets (a, button, headings) into @layer base. Don't move the whole file into a layer: a class that shares a utility's name, such as .container, then loses to the utility and the layout changes without a warning.
A site on @clinia/web-cookiebot moves to Cookie Consent: add @clinia/cookie-consent, mount it once in the root layout with the site's categories, then uninstall the package and remove its @clinia entry from .npmrc if nothing else needs GitHub Packages. It reads the same cc_cookie, so users keep their earlier choice. Cookie Consent has no banner title and no free-text sections in its preferences, so web-cookiebot's title and info copy are dropped: put what users need to know in message and in each category's description. A site with only necessary cookies removes the banner instead, and says so in its privacy policy.
Keep the files the registry writes out of your formatter: components/ui/, lib/utils.ts, icons.manifest.ts, the icon scripts and eslint.clinia.mjs. Add them to .prettierignore or its equivalent, so the next reinstall shows only Clinia's changes.
7. Import selected icons
import { CheckIcon } from "@/components/ui/icons/check"
<CheckIcon />
<CheckIcon size={16} />
<CheckIcon label="Close dialog" />- Icons are 20px by default. The standard sizes are 16, 20 and 24.
- Icons draw in the
currentColorof the surrounding text or section. - An interactive icon needs a
labelfor accessibility. Leave thelabeloff only for decorative icons. - Import each icon from its own file, as above. Don't wrap icons in a component that picks one by name: it loads every icon into the page.
8. Add the lint rules
The lint rules keep the migration in place. Without them, the next shadcn add quietly brings back a second icon set, and raw values creep back into the app's own code.
pnpm dlx shadcn@latest add @clinia/eslintThis writes eslint.clinia.mjs. Spread it last in your eslint.config.mjs:
import clinia from "./eslint.clinia.mjs"
export default [...yourConfig, ...clinia]Outside components/ui/, the rules reject other icon libraries, raw --clinia-* swatches, arbitrary text sizes such as text-[13px], font-semibold and font-bold, rounded-none, corners above rounded-lg and arbitrary radii, hex colours in classes such as bg-[] or in a style prop, role="button" on an element that isn't a button, and a logo drawn with <img>. They read JavaScript and TypeScript only. They don't catch arbitrary leading-[…] or tracking-[…], rgba() in classes, or anything in a CSS file: search for those by hand. Once the rules are installed, pnpm lint counts what they do catch.
In a monorepo with one flat config at the root, scope Clinia's rules to the app. Its blocks match **/*, so imported as they are, they lint every package. Prefix each block's files and ignores with the app's folder. If the root config sets no-restricted-imports for the same files, merge its options into Clinia's block: a later block replaces the rule's options rather than adding to them.
Then uninstall any other icon library your app still lists in package.json, such as lucide-react or @hugeicons/react.
9. Components Clinia doesn't ship
Don't install them straight from shadcn: request them. The page also covers what to do if you can't wait.
10. Replace the brand assets
The logo, the favicon and the app icons don't change with the components. Replace them, or the migrated app keeps showing the old ones.
pnpm dlx shadcn@latest add @clinia/logo
curl -LO https://ui.clinia.dev/favicon/favicon.zip
unzip favicon.zip -d public/favicon
cp public/favicon/light/favicon.ico public/favicon.ico- Replace each logo
<img>with the inlineLogoorLogomark, coloured withtext-foreground. An<img>can't follow the theme. See Logo. - Replace the favicon, the app icons and the manifest icons with the files from
favicon.zip, set the<link>tags as Favicon shows, and delete the old files. - On a marketing site, replace any copies of the compliance badges with the files on Security & compliance, and their captions with its approved titles and lines. A site's own copies drift in size and wording.
11. Align each screen with its conventions
New tokens and components don't make a screen look like Clinia on their own. Go through each screen, list the patterns the app built by hand, and replace each with the Clinia component that does the job. In a product app:
| Built by hand | Clinia component |
|---|---|
Sidebar navigation, with links made of div role="button" | Sidebar |
| List rows with an icon, a title and actions | Item |
| "Loading…" text | Skeleton shaped like the content, or Spinner inside the button that started the action |
| "No results" or "Nothing here yet" text | Empty |
A logo <img> | Logo, from step 10 |
| A label, an input, its hint and its error | Field |
| Status pills | Badge |
Then check each screen against the App conventions: Empty states, Loading, Forms and validation and Destructive actions.
On a marketing site:
| Built by hand | Clinia component |
|---|---|
| A header bar | Site header: transparent over the home page's hero, solid on inner pages |
| A hero or section CTA pair, or any link styled as a button | CTA |
| A row of customer logos | Logo Strip |
| Compliance badges and their captions | The files and lines on Security & compliance, from step 10 |
| A decorative background behind a hero or a closing CTA band | Brand Background |
| Hand-drawn line loops | The Infinity Band lines on Line & shape |
| A product name beside the logo, or a "powered by Clinia" line | The product lockup |
Then check each page against the Marketing conventions: Getting started, Line & shape, Imagery and Motion.
A hand-built card that already matches Card through tokens (the border hairline, bg-card and 8px corners) needs no swap. Keep its element, such as an <article>.
A component can bring behaviour the app doesn't have, such as Sidebar's collapse to icons and its mobile drawer. Decide on that before adopting it. Matching its look, such as the active item's tint and spacing, needs no decision.
Two layout traps when you swap hand-built cards and links:
buttonVariants()returns the variant's classes without merging them. On a<Link>, wrap it incn(), or the outline variant keepsborder-transparentand loses its border. Don't use<Button render={<Link />}>on a link: it addsrole="button".- Item wraps by default. To stack it as a card, add
flex-nowrapwithflex-col, orItemHeaderandItemFooterwrap into columns of their own.ItemTitleandItemDescriptionclamp to one and two lines; addline-clamp-nonewhere the full text must show.
Read the screenshots, don't only diff them: a card laid out wrong overflows nothing, so a script that checks for clipped text won't catch it.
The migration is done when no screen has a div with role="button", a bare "Loading…" or a logo <img>, not when the tokens are swapped.
Coming from Radix
shadcn's earlier styles are built on Radix, and Clinia's components on Base UI. Reinstalling your components (step 6) swaps the primitive under each one, so code that uses Radix's API changes with it. This is where a migration can change behaviour: test each change.
| Radix | Base UI, in Clinia's components | Test |
|---|---|---|
asChild on a trigger | render, with the element to render | The element is still a link or a button, with its href, type, and disabled state |
Accordion type="single", defaultValue="a" | defaultValue={["a"]}: always an array | Which items start open, and whether several can |
| Select shows the chosen item's text | Pass items, or the trigger shows the raw value | The visible label, and the value the form submits |
checked="indeterminate" | The indeterminate prop; checked is a boolean | Select-all states |
onOpenChange, onValueChange, onCheckedChange | The same names, with the event details as a second argument | Handlers that read the first argument work unchanged |
Overlay content in a Portal | Portal, Positioner, then Popup: the component sets them up | Stacking above sticky headers, and placement near the screen's edge |
data-[state=open]: styles | data-open:, data-checked: and the theme's other variants match both libraries | Open, closed, and checked styles |
The change you'll make most is asChild to render:
// Radix
<DialogTrigger asChild>
<Button variant="outline">Edit profile</Button>
</DialogTrigger>
// Base UI, in Clinia's components
<DialogTrigger render={<Button variant="outline">Edit profile</Button>} />Walk every dialog, menu, and popover with the keyboard after the switch: focus moves in on open, Escape closes, and focus returns to the trigger.
From Clinia UI 1.x
An app on @clinia/tailwindcss 0.x is partly on Clinia UI already. The site and the package number their versions differently: Clinia UI 1.x shipped @clinia/tailwindcss 0.x, and Clinia UI 2.0 ships @clinia/tailwindcss 2.0. Version 2.0 changes the foundations under it, so check each of these on top of the steps above. The changelog has the full Breaking list.
| In a 1.x app | In 2.0 | What to do |
|---|---|---|
@clinia/tailwindcss 0.x | 2.0 | Upgrade the package: pnpm add @clinia/tailwindcss@2. If @clinia maps to GitHub Packages, see step 3. If pnpm adds the package to minimumReleaseAgeExclude, keep that line. |
@clinia/tailwindcss/style-luma or style-sera imports, data-style on an element | Removed | Delete them. Shape comes from the shadcn style in components.json. |
Another shadcn style, such as base-maia | base-luma for product apps, base-sera for marketing sites | Set the style, then reinstall every component (step 6). Product screens use Luma, even admin areas that used Sera. |
| Vital as the accent | Trust | Reinstalled components follow on their own. In the app's own code, move --clinia-vital and accent uses to semantic tokens, and keep small text off Trust. |
| Gothia Serif | NaN Serf B, Book 400 only | Replace the font files and drop any weight above 400 on display text. |
font-semibold, font-bold | Not shipped: CircularXX goes up to 500 | Use font-medium. |
| No animation imports | The theme imports tw-animate-css and shadcn/tailwind.css | Add the theme item again (step 3), so overlays and accordions animate. |
| HugeIcons, Lucide or inlined icons | Material Symbols modules | Steps 5 and 7. |
Documentation sites on Fumadocs
A docs site built on fumadocs-ui keeps Fumadocs' layout, search, table of contents and API reference, and takes Clinia's colours, states and type. Developer documentation is an App surface: set base-luma.
- Replace the Fumadocs colour preset (
fumadocs-ui/css/neutral.cssand its siblings) with@clinia/tailwindcss/fumadocs. The preset sets its own dark sidebar colours, which override any mapping you add. Keepfumadocs-ui/css/preset.css: it holds the layout, not colours. - Import Clinia after Fumadocs. Both declare a
darkvariant, and the later one wins:
@import "tailwindcss";
@import "fumadocs-ui/css/preset.css";
@import "@clinia/tailwindcss/fonts";
@import "@clinia/tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@clinia/tailwindcss/fumadocs";- Map the MDX components onto Clinia's in the page's
components, so no.mdxfile changes:Calloutto Alert andCardto Item, with Fumadocs' props. Alert is a live region (role="alert"): passrole={undefined}for a static callout. Keep a card's title a heading. - If the site uses
fumadocs-openapi, check its method labels and status colours. They use Tailwind's palette directly, andtext-green-600fails AA on white. - Give highlighted code a dark theme that passes on your dark card. Shiki's GitHub dark theme draws comments at 3.25:1. Clinia's theme ships in
@clinia/code-blockaslib/code-theme.ts.
Before you ship
Quality gates to make sure all guidelines are followed:
In the code
pnpm lintpasses. This makes sure conventions are followed, and that the rules from step 8 catch any icon import, hand-built button or logo image that slipped through, and the raw values they cover.
On each screen
- It uses the components from step 11, not hand-built versions of them, and follows its App or Marketing conventions page.
- The logo is the inline
Logo, and the tab shows Clinia's favicon in both themes.