Clinia UI

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:

json
{
  "$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"
    }
  }
}
  • style is the surface: base-luma for a product app, base-sera for a marketing site (step 2).
  • tailwind.css is the file that imports Tailwind. The theme replaces it in step 3. Without a src/ folder, it's app/globals.css.
  • The aliases need "@/*" in the paths of tsconfig.json, pointing to ./src/*, or to ./* without a src/ 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:

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.

json
{
  "style": "base-luma"
}

Marketing site: shadcn's Sera style.

json
{
  "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:

bash
pnpm dlx shadcn@latest add @clinia/theme

Marketing site:

bash
pnpm dlx shadcn@latest add @clinia/theme-marketing

The 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:

bash
CI=1 pnpm dlx shadcn@latest add @clinia/theme --yes --overwrite

The 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:

bash
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"
done

The fonts are licensed: don't commit them to a public repository.

FileFamilyWeightStyle
CircularXXWeb-Book.woff2CircularXXBook 400Normal
CircularXXWeb-BookItalic.woff2CircularXXBook 400Italic
CircularXXWeb-Regular.woff2CircularXXRegular 450Normal
CircularXXWeb-Medium.woff2CircularXXMedium 500Normal
CircularXXWeb-MediumItalic.woff2CircularXXMedium 500Italic
CircularXXMonoWeb-Regular.woff2CircularXX MonoRegular 400Normal
NaNSerfBText-Book.woff2NaN Serf BBook 400Normal

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:

css
: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:

bash
pnpm dlx shadcn@latest add @clinia/icons

Marketing site: add the marketing icons too.

bash
pnpm dlx shadcn@latest add @clinia/icons @clinia/marketing-icons

This 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.

bash
pnpm dlx shadcn@latest add @clinia/button @clinia/select --overwrite

This 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:

bash
pnpm dlx shadcn@latest add @clinia/utils --overwrite

It 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

tsx
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 currentColor of the surrounding text or section.
  • An interactive icon needs a label for accessibility. Leave the label off 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.

bash
pnpm dlx shadcn@latest add @clinia/eslint

This writes eslint.clinia.mjs. Spread it last in your eslint.config.mjs:

js
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.

bash
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 inline Logo or Logomark, coloured with text-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 handClinia component
Sidebar navigation, with links made of div role="button"Sidebar
List rows with an icon, a title and actionsItem
"Loading…" textSkeleton shaped like the content, or Spinner inside the button that started the action
"No results" or "Nothing here yet" textEmpty
A logo <img>Logo, from step 10
A label, an input, its hint and its errorField
Status pillsBadge

Then check each screen against the App conventions: Empty states, Loading, Forms and validation and Destructive actions.

On a marketing site:

Built by handClinia component
A header barSite header: transparent over the home page's hero, solid on inner pages
A hero or section CTA pair, or any link styled as a buttonCTA
A row of customer logosLogo Strip
Compliance badges and their captionsThe files and lines on Security & compliance, from step 10
A decorative background behind a hero or a closing CTA bandBrand Background
Hand-drawn line loopsThe Infinity Band lines on Line & shape
A product name beside the logo, or a "powered by Clinia" lineThe 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 in cn(), or the outline variant keeps border-transparent and loses its border. Don't use <Button render={<Link />}> on a link: it adds role="button".
  • Item wraps by default. To stack it as a card, add flex-nowrap with flex-col, or ItemHeader and ItemFooter wrap into columns of their own. ItemTitle and ItemDescription clamp to one and two lines; add line-clamp-none where 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.

RadixBase UI, in Clinia's componentsTest
asChild on a triggerrender, with the element to renderThe element is still a link or a button, with its href, type, and disabled state
Accordion type="single", defaultValue="a"defaultValue={["a"]}: always an arrayWhich items start open, and whether several can
Select shows the chosen item's textPass items, or the trigger shows the raw valueThe visible label, and the value the form submits
checked="indeterminate"The indeterminate prop; checked is a booleanSelect-all states
onOpenChange, onValueChange, onCheckedChangeThe same names, with the event details as a second argumentHandlers that read the first argument work unchanged
Overlay content in a PortalPortal, Positioner, then Popup: the component sets them upStacking above sticky headers, and placement near the screen's edge
data-[state=open]: stylesdata-open:, data-checked: and the theme's other variants match both librariesOpen, closed, and checked styles

The change you'll make most is asChild to render:

tsx
// 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 appIn 2.0What to do
@clinia/tailwindcss 0.x2.0Upgrade 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 elementRemovedDelete them. Shape comes from the shadcn style in components.json.
Another shadcn style, such as base-maiabase-luma for product apps, base-sera for marketing sitesSet the style, then reinstall every component (step 6). Product screens use Luma, even admin areas that used Sera.
Vital as the accentTrustReinstalled 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 SerifNaN Serf B, Book 400 onlyReplace the font files and drop any weight above 400 on display text.
font-semibold, font-boldNot shipped: CircularXX goes up to 500Use font-medium.
No animation importsThe theme imports tw-animate-css and shadcn/tailwind.cssAdd the theme item again (step 3), so overlays and accordions animate.
HugeIcons, Lucide or inlined iconsMaterial Symbols modulesSteps 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.

  1. Replace the Fumadocs colour preset (fumadocs-ui/css/neutral.css and its siblings) with @clinia/tailwindcss/fumadocs. The preset sets its own dark sidebar colours, which override any mapping you add. Keep fumadocs-ui/css/preset.css: it holds the layout, not colours.
  2. Import Clinia after Fumadocs. Both declare a dark variant, and the later one wins:
css
@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";
  1. Map the MDX components onto Clinia's in the page's components, so no .mdx file changes: Callout to Alert and Card to Item, with Fumadocs' props. Alert is a live region (role="alert"): pass role={undefined} for a static callout. Keep a card's title a heading.
  2. If the site uses fumadocs-openapi, check its method labels and status colours. They use Tailwind's palette directly, and text-green-600 fails AA on white.
  3. 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-block as lib/code-theme.ts.

Before you ship

Quality gates to make sure all guidelines are followed:

In the code

  • pnpm lint passes. 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.