skip to content
cd/
projects

cd/ui

React components you install right into your project, and you can see how much each one weighs.

see it liveview repository

screenshot of cd/ui

tl;dr#

cd/ui is 32 React components and 15 blocks (ready-made screens), all on Base UI + Tailwind v4, as a shadcn registry. You drop the code straight into your project and tweak it however you want. Every build measures each component’s gzip size; right now the average is about 695 B.

docs · blocks · performance · code

the problem#

a component library is usually a dependency: you install it, import it and hope it keeps doing what you need. when some detail has to change, you end up fighting its API or its CSS.

and pretty much every library says it's "lightweight". almost none tells you how much.

I wanted the opposite:

the idea#

three simple bets:

  1. a registry instead of a package. cd/ui never goes into node_modules. one command copies the component file into your project, and from then on the code is yours.
  2. measure at build time. the size comes from esbuild, not from an estimate, and it shows up on every docs page.
  3. server first. a component with no state is a Server Component and ships no JS. "use client" only shows up when there's state or events.

what it looks like#

no shadcn init needed. you create a tiny components.json:

json
{
  "style": "new-york",
  "tailwind": { "css": "src/app/globals.css", "baseColor": "neutral" },
  "aliases": { "components": "@/components", "utils": "@/lib/utils" }
}

and one command installs the theme, utils and every single component:

bash
npx shadcn@latest add https://cd-ui.vercel.app/r/all.json

blocks have all-blocks.json. single items work too, by URL or by registering the namespace in components.json:

json
{ "registries": { "@cd": "https://cd-ui.vercel.app/r/{name}.json" } }
bash
npx shadcn@latest add @cd/button @cd/dialog

the dependencies (Base UI, and Zod when needed) and any other components it uses come along for the ride. and the home page already gets the idea across.

cd/ui home: the prompt shows the current route, Ctrl K opens search and the 695 B gzip average is measured at build time
cd/ui home: the prompt shows the current route, Ctrl K opens search and the 695 B gzip average is measured at build time

every component has its own page, with preview, code, API, keys and accessibility notes. the size badge and the client/server badge come from the build measurement.

the Button docs page: badges with the gzip size and where it runs, preview and code tabs and the rsc marker in the sidebar
the Button docs page: badges with the gzip size and where it runs, preview and code tabs and the rsc marker in the sidebar

on top of the components, cd/ui has 15 blocks: full ready-made screens (5 for auth, 7 for marketing and 3 for apps, like login, hero, pricing, FAQ and settings), built only from the library's own components. a block installs with one command and pulls in the components it uses:

bash
npx shadcn@latest add https://cd-ui.vercel.app/r/login-01.json

each block page shows the preview at desktop, tablet and phone sizes, the code, and opens full screen. the docs and the blocks exist in Portuguese and in English.

the login-01 block page: the install command, the preview and code tabs with desktop, tablet and phone sizes and the link to open full screen
the login-01 block page: the install command, the preview and code tabs with desktop, tablet and phone sizes and the link to open full screen

how it works under the hood#

the repository has two sources of truth: the files in src/registry/cd/ui/*.tsx (what gets distributed) and src/docs/catalog.json (name, title, category and description of each component). blocks follow the same scheme, in src/registry/cd/blocks and src/blocks/catalog.json. the build reads all of it and generates what the site and the CLI need.

src/registry/cd/ui/*.tsx ─┬─► build-registry.mjs ─► registry.json ─► shadcn build ─► public/r/*.json
                          │
                          └─► metrics.mjs ─► esbuild + gzip ─► src/docs/metrics.json ─► docs

bun run build runs registry:build (both scripts and shadcn build) and only then next build.

nobody writes the registry by hand#

scripts/build-registry.mjs finds the dependencies by reading the file's own imports:

js
function importsOf(file) {
  const src = readFileSync(file, "utf8");
  return [...src.matchAll(/^import\s+(?:type\s+)?[^"']*["']([^"']+)["']/gm)]
    .filter((m) => !/^import\s+type\s/.test(m[0]))
    .map((m) => m[1]);
}

anything that isn't @/ or react becomes an npm dependency. anything under @/registry/cd/ui/other becomes a registry dependency (the URL of the other component). the theme item copies the tokens from globals.css, the :root and .dark blocks.

bottom line: if a component starts importing something else, the registry already knows. there's no list to forget to update. the same goes for blocks: the components they import become registry dependencies, so installing a block installs everything it uses.

what I learned installing it into a real project#

when I installed cd/ui into a project outside the repo, four problems popped up that my own site never showed me:

how the weight is measured#

scripts/metrics.mjs bundles each component with esbuild and measures it in gzip:

js
const result = await build({
  entryPoints: [file],
  bundle: true,
  minify: true,
  write: false,
  format: "esm",
  // libraries stay out; cd/ui's own code goes in
  external: ["react", "react/*", "react-dom", "@base-ui/react", "@base-ui/react/*", /* … */ "zod", "zod/*"],
});
const code = result.outputFiles[0].contents;
metrics[name] = { gzip: gzipSync(code, { level: 9 }).length, /* … */ };

the libraries are left out on purpose: Base UI and Zod already live in your project or are shared. the number is the code that enters your project on top of them, which is the part cd/ui controls.

the same script flags each component as client or server just by checking whether the file starts with "use client":

js
client: /^["']use client["']/.test(source),

the numbers on the site come from that measurement. all 32 components, smallest to largest:

componentgzipruns on
skeleton234 Bserver
kbd282 Bserver
separator284 Bserver
card377 Bserver
avatar383 Bclient
spinner398 Bserver
textarea451 Bclient
field451 Bclient
badge453 Bserver
progress465 Bclient
popover478 Bclient
radio-group522 Bclient
input525 Bclient
otp-input539 Bclient
form553 Bclient
auth-shell573 Bserver
tabs613 Bclient
switch660 Bclient
slider667 Bclient
checkbox688 Bclient
alert776 Bserver
tooltip794 Bclient
pricing-toggle885 Bclient
dialog898 Bclient
accordion912 Bclient
table930 Bserver
dropdown-menu1.0 kBclient
logo1.1 kBclient
button1.2 kBclient
select1.2 kBclient
toast1.3 kBclient
password-input1.7 kBclient

average of about 695 B. 9 of the 32 (alert, auth-shell, badge, card, kbd, separator, skeleton, spinner and table) ship no JS.

Zod forms without loading all of Zod#

Form only imports Zod's core, zod/v4/core:

tsx
import { type $ZodObject, flattenError, type output, safeParse } from "zod/v4/core";

so it accepts schemas from zod and from zod/mini. you pass the schema, give the Fields a name, and each field validates its own schema key when you leave it:

tsx
const validateField = (name: string, value: unknown) => {
  const field = schema._zod.def.shape[name];
  if (!field) return null;
  const result = safeParse(field, value);
  return result.success ? null : result.error.issues.map((issue) => issue.message);
};

on submit the whole schema runs. if it fails, the errors go to each field's FieldError. if it passes, onSubmit receives the data already converted and typed by the schema:

tsx
const schema = z.object({
  email: z.email("invalid e-mail"),
  age: z.coerce.number().min(18, "must be 18+"),
})

<Form schema={schema} onSubmit={(values) => {
  values.age // number, not string
}}>
  <Field name="email">…</Field>
  <Field name="age">…</Field>
</Form>

decisions#

copy instead of depend. a component is just a file of yours. if the button needs another variant, you edit the file and that's it. the catch is that updating later is on you.

Base UI underneath. focus, keyboard and aria come from Base UI. cd/ui adds the look and the variants on top, and every component page lists the keys and the accessibility notes.

import only what it uses. each component imports the part of Base UI it needs (@base-ui/react/dialog), never the whole package. animations, the textarea growing and the spinner are CSS.

motion with a reason. the button sinks 2% in 80 ms when clicked. in general it's 80 to 240 ms, strong curves, only transform and opacity, and prefers-reduced-motion respected in all of them. search (⌘K) opens and closes with no animation at all: if you use the shortcut, you open it hundreds of times a day.

the prompt is the logo. the docs header shows cd/ui ~/docs/button $ with a blinking cursor: the text is the logo and it also tells you which page you're on.

the component name is validated. the name becomes a file path and a URL, so the build only accepts lowercase letters, numbers and hyphens (/^[a-z][a-z0-9-]*$/).

what changed later#

status and next steps#

cd/ui is at v0.2, with 32 components, 15 blocks, light and dark themes and the docs site live, in Portuguese and English. it's open source: MIT license, a contributing guide and CI that runs lint, types and build on every pull request. to be upfront about the measurement: it counts cd/ui's code, not the Base UI or Zod that come with it.

adding a component is quick: the file in src/registry/cd/ui, the catalog entry, the examples and the docs, and bun run registry:build generates the rest.