Skip to content
Nana UI

Guides

Owning the Source

Copy Nana UI components into your project with the CLI, and change them however you like.

View .md Download

There are two ways to use Nana UI, and they solve different problems.

Installing @nana-tec/ui-components gives you a versioned dependency: upgrades arrive with npm update, and the component internals stay ours. That is the right default, and most projects should stop there.

The CLI gives you the other trade. It copies component source into your repository, where you can read it, change it, and take it in a direction the design system never planned for — at the cost of no longer receiving updates automatically.

npx @nana-tec/ui init
npx @nana-tec/ui add button

What lands on disk depends on the framework you are using. init detects it and records it in nana-ui.json, and add serves the matching implementation:

FrameworkDetected fromYou getComponents
Reactreact or next in dependenciesbutton.tsx, styled with Tailwind14
Sveltesvelte.config.js, or svelte in dependenciesButton.svelte, styled with Tailwind14
Web Componentsanything elsenana-button.ts + Lit styles65

React and Svelte ship the same core set:

alert, badge, button, card, checkbox, dialog, dropdown, input, radio, select, switch, table, tabs, tooltip.

Web Components remain the widest surface at 65, including the pieces the core set does not cover yet — editor, carousel, date range, colour picker, QR code and the rest. Run nana-ui list to see what your framework actually offers.

These are not one component wrapped three ways. Each is written natively, so a React button is a real React component with forwardRef and typed props, and a Svelte one uses runes and snippets. What they share is the design — the same tokens, variants, sizes, focus behaviour and reduced-motion handling — not the implementation.

React
src/
├── components/ui/button/
│ ├── button.tsx
│ ├── index.ts
│ └── types.ts
└── lib/nana-ui/
├── styles/button.ts
├── utils/cn.ts
└── utils/types.ts
import { Button } from "@/components/ui/button";
<Button variant="primary" size="lg">Save</Button>
Svelte
src/lib/
├── components/ui/button/
│ ├── Button.svelte
│ ├── index.ts
│ └── types.ts
└── nana-ui/
├── styles/button.ts
├── utils/cn.ts
└── utils/types.ts
<script lang="ts">
import { Button } from '$lib/components/ui/button';
</script>
<Button variant="primary" size="lg">Save</Button>
Web Components
src/
├── components/ui/button/
│ ├── index.ts
│ ├── nana-button.ts
│ └── nana-button.styles.ts
└── lib/nana-ui/utils/
├── custom-element.ts
└── types.ts
import "@/components/ui/button/nana-button.js";
<nana-button variant="primary">Save</nana-button>

Web Components has the full library — 72 components and a block. React and Svelte carry the core set: button, input, select, dialog, dropdown, tabs, table, pagination, checkbox, radio, switch, tooltip, card, badge, divider, alert, empty state, meter input and bulk progress, plus five blocks.

npx @nana-tec/ui list

lists what your framework’s registry actually offers, so it is always accurate rather than whatever this page last said.

Paths and aliases follow each framework’s conventions — $lib/components/ui on Svelte, @/components/ui on React — and init reuses whatever alias your tsconfig already declares.

Components are not standalone files. nana-editor uses nana-icon and nana-color-picker; almost everything uses the shared customElement helper and type definitions. The registry records those relationships, so one command installs the whole set:

npx @nana-tec/ui add editor
> resolving 1 component
pulling in color-picker, icon, utils-custom-element, utils-types

Relative imports are rewritten to your project’s aliases as the files are written, so nothing points back at the library.

Design tokens do. Colours, spacing, radii and typography live in @nana-tec/ui-tokens and @nana-tec/ui-themes, which init installs for you:

@import "@nana-tec/ui-themes/base";

Ejecting tokens as well would freeze every project’s palette at the version it installed on, and the point of a design system is that this cannot happen. You own the components; the system owns the variables they read.

You do not have to eject a component to restyle it. Every component exposes CSS custom properties and ::part hooks first — see Reusability & Composition.

<nana-button style="--nana-button-bg:#2563eb">Buy now</nana-button>

The written files import each other through the aliases recorded in nana-ui.json, so your bundler and tsconfig both have to resolve them. init reads compilerOptions.paths and reuses whatever prefix it finds; if there is none, it tells you what to declare.

This means the CLI targets bundler-based projects — Vite, Next, Astro, Rspack. If you load Nana UI from a CDN, install the npm package instead.

The project also has to compile TypeScript.

Web Components only: that target uses Lit, whose decorators most templates — create-next-app among them — do not enable. init checks and offers to add:

{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}

Without both, the copied files are written correctly but fail to typecheck. React and Svelte need none of this — decorators are a Lit concern, so init skips the check entirely on those targets.

init records TypeScript support by looking for a tsconfig.json or jsconfig.json, and add refuses on a project without one rather than writing files it cannot build. There is no JavaScript output on purpose: the components use decorators, so a compiled copy would be __decorate([property({ type: Boolean })], …) rather than source worth owning — which defeats the point of copying it in at all. On a JavaScript project, install @nana-tec/ui-components from npm; the components are standards-based custom elements and work the same.

There is no autoloader for copied components, and there cannot be: the autoloader resolves a tag name to a module inside the package, and your copies live at paths only your project knows. Each file registers its own tag when it is imported, so importing it once anywhere — an entry point, or the module that renders it — is what makes the tag work:

import "@/components/ui/button/nana-button.js";

The two models mix, so a project can autoload most of the library from npm and copy in only the components it needs to change.

npx @nana-tec/ui update # every installed component
npx @nana-tec/ui update button # just this one

update can tell an untouched file from one you have changed, because add records what it wrote in nana-ui.lock.json. Files still matching that record are refreshed in place; files you have edited are reported and left alone unless you pass --force.

That distinction is the reason the lock file exists. Once source lives in your repository, a file that differs from the registry is ambiguous on its own — it could be an upstream improvement you have not taken yet, or the change you made on purpose. Only a record of what was installed can tell the two apart.

Commit nana-ui.lock.json. It is how the next person, and the next machine, get the same answer.

A block is a composed starting point rather than a primitive: it arrives already assembled from several components, and you are expected to rewrite it. Blocks currently exist for the Web Components target only.

npx @nana-tec/ui add login-form
> resolving 1 component
pulling in alert, button, card, checkbox, input, utils-custom-element

Blocks are the clearest case for owning source. A login form is nine tenths decisions your product has already made — which fields, what the copy says, where it posts — so a configurable component would need a prop for each of them. Copied in, it is just a file you edit.

add --all installs every component but no blocks, because a block is a choice rather than a building block.

<nana-login-form heading="Welcome back" forgot-href="/reset"></nana-login-form>
document.querySelector("nana-login-form")
.addEventListener("nana-login", (e) => signIn(e.detail));

The React and Svelte components are Tailwind. They ship as utility classes rather than a stylesheet, so changing one is editing a string rather than hunting through CSS.

Every component’s variants, sizes and shapes live in one file of its own, next to the component but apart from it — styles/button.ts for the button:

export const variants: Record<ButtonVariant, string> = {
primary: 'bg-primary text-primary-foreground hover:brightness-90',
ghost: 'bg-transparent text-foreground border-line hover:bg-surface-muted',
};

That file is where you restyle a component. Editing it changes every button in the project at once, and the component itself stays about rendering — it reads the map, it does not contain it.

Two reasons it is split out rather than sitting in the component. Restyling should not mean reading past a component’s logic to find a string. And React and Svelte are separate implementations of one design: keeping the classes in a file they both read is what stops the same variant looking different in the two frameworks, which is not hypothetical — it had already happened twice before the split.

Note what those class names are: bg-primary, not bg-neutral-900. They are semantic names that resolve to design tokens, which is what keeps the components following the design system instead of drifting from it.

add brings in styles-nana-theme, a @theme block mapping the --nana-* tokens onto Tailwind’s theme:

@theme inline {
--color-primary: var(--nana-surface-accent);
--color-primary-foreground: var(--nana-surface-accent-contrast);
--color-line: var(--nana-surface-border);
--radius-md: var(--nana-radius-md);
--text-sm: var(--nana-typography-font-size-sm);
}

Import it once, after Tailwind:

@import "tailwindcss";
@import "@nana-tec/ui-themes/base";
@import "@/lib/nana-ui/styles/nana-theme.css";

Now bg-primary in a component and bg-primary in your own markup are the same colour, and re-theming a token moves both. That is the whole point of the bridge: you get editable utility classes without the design system losing control of the values.

Tailwind v4 is required for the React and Svelte components, along with clsx and tailwind-merge — the CLI installs the latter two for you. Classes you pass are merged with tailwind-merge, so your override wins rather than fighting:

<Button variant="primary" className="rounded-none px-8">Save</Button>

If your project already has its own design tokens mapped into Tailwind, you do not need the bridge at all. Skip styles-nana-theme and rewrite the variant strings against your own names:

const variants = {
primary: 'bg-foreground text-background',
accent: 'bg-accent text-background',
};

That is the model working as intended — you own the file, and the component now follows your system rather than ours.

That target keeps its shadow-DOM CSS, because Tailwind cannot reach inside a shadow root. Utilities on a nana-* element apply to the host, so layout classes (mt-4, w-full) work while appearance ones do not. Change appearance through the CSS variables instead:

<nana-button style="--nana-button-bg: var(--color-primary)">Save</nana-button>

add takes the current release by default. Name an exact version to install from a published snapshot instead:

npx @nana-tec/ui add button@0.0.25

Snapshots are immutable and stay online after later releases, so a project can reproduce an install months later. They are per framework, at /r/<framework>/v/<version>/. One command installs from one version — components share helpers, and mixing versions across a dependency graph is how you get a component calling into a helper that has since changed shape. Ranges are rejected rather than resolved, because a source copy is a one-time decision and ^1 would mean something different every time you ran it.

Only releases from 0.0.25 onward have snapshots; earlier versions predate them.

npx @nana-tec/ui remove button

Shared helpers are kept if another installed component still needs them, and a file you have edited is never deleted without --force. Empty directories are cleaned up afterwards.

npx @nana-tec/ui list --installed

shows what this project has, and which version each came from.

CommandWhat it does
initWrites nana-ui.json and installs the theme packages
add <name…>Copies components in, with every dependency they need
add --allCopies in every component
add <name>@<version>Copies in from a pinned release
update [name…]Refreshes installed components, skipping ones you edited
remove <name…>Deletes components, keeping helpers others still need
listShows what the registry offers
list --installedShows what this project has
FlagEffect
--dry-runReports what would change, writes nothing
--overwriteadd: replaces files that differ from the registry copy
--forceupdate / remove: act on files you have edited
--skip-installWrites files without touching package.json
--cwd <dir>Runs as if started in another directory
--registry <url>Reads from a different registry