get started · installation

two ways in

every component ships both as part of the package and as copyable source in the registry, from one codebase. you do not pick once for the project. you pick per component, and you can change your mind later by copying the source in and dropping the import.

what it expects to find

React 18 or 19, and Tailwind v4. the v4 part is not negotiable: the tokens ship as a @theme block and an @import, neither of which v3 can read. there is no config file to write and no plugin to register.

it works wherever React does. Next, including the app router and server components: every component that needs the client declares it, and the build splits client from server per file, so there is no wrapping and no ssr: false. Vite and Astro the same, on React 18 or 19.

the commands

copy the source in, via shadcn:

npx shadcn add https://usva.build/r/button.jsoncopy
soonnot on npm yet

the tokens package comes along with the npm install. if you copy source in instead, install usva-tokens yourself: the source consumes role tokens and will render unstyled without it.

take the package when

  • you want fixes and new variants to arrive with a bun update, not a diff review
  • you are using a lot of it, and 79 vendored components is not a codebase you want to own
  • you would rather the package be the thing that moves, and your tree stay still

copy the source when

  • you need to change the component itself, not just its tokens. a different DOM, a different behaviour
  • you have a policy against runtime dependencies in the UI layer
  • you want exactly one component and nothing else in your bundle

the licence, before you copy

the source you copy in is still licensed. usva. is mit with the commons clause: build on it, fork it, change it, ship it in whatever you make. the one line you cannot cross is repackaging the components themselves to sell or republish them as a competing library.

the cn util, if you copy source

copied source imports cn from @/lib/utils, the same contract shadcn uses. if your project ran npx shadcn init it is already there, and the add command wires everything up. copying by hand instead, create it once:

lib/utils.tscopy
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

it needs clsx and tailwind-merge. a component that leans on a sibling, like Button on Spinner, declares it as a registry dependency, so the add command copies both. hand copiers grab the sibling from its own page.

wire up the CSS

usva. is Tailwind v4. the roles arrive as CSS variables and as utilities, so a component can say bg-surface and mean it in any theme.

app/globals.csscopy
@import "tailwindcss";
@import "usva-tokens/theme.css";
@import "usva-tokens/themes/kajo.css";

/* the utilities live in the built package, so tailwind has to see it */
@source "../node_modules/usva/dist/**/*.js";

the @source line is the one people miss. tailwind only generates the utilities it can see used, and it cannot see inside a dependency unless you point at it. if a component renders with no colour, this is why. copying source in instead, you do not need it: the files land in your own tree, which tailwind already scans.

if you ran shadcn init, order matters

shadcn init appends its own @theme inline block to your css, and four of its role names are also ours: border, accent, muted and ring. whichever block is read last wins, and init appends, so it wins by default.

src/index.csscopy
@import "tailwindcss";

/* whatever shadcn init wrote, left exactly where it put it */
@import "shadcn/tailwind.css";
@theme inline {
  /* ... */
}

/* usva last. its @theme has to be read after shadcn's @theme inline */
@import "usva-tokens/theme.css";
@import "usva-tokens/themes/kajo.css";

the failure is quiet, which is what makes it worth knowing. the other twenty-one roles still resolve, so the component looks deliberate and only those four colours are someone else’s. an outline Button with a near-white border instead of a dim one is the usual tell. move the usva imports to the bottom and all four come back.

then use it

anywherecopy
import { Button } from "usva";

export function Save() {
  return <Button variant="solid">save</Button>;
}
what you should be seeingrendered here, now

that is the real component, on this page, from the same package you just installed. tab to it and it takes a focus ring; the colour moves over 150ms and the surface does not. if yours came out unstyled, the @source line above is almost always the reason.

next: make it yours without forking it.