cd/ui
Componentes React que você instala direto no seu projeto, e dá pra ver quanto cada um pesa.
- React
- TypeScript
- Base UI
- Tailwind v4
- shadcn registry
- Zod

tl;dr#
O cd/ui junta 32 componentes React e 15 blocos (telas prontas), tudo em Base UI + Tailwind v4, num registry do shadcn. Você instala o código direto no seu projeto e mexe como quiser. O build mede o tamanho de cada componente em gzip; hoje a média fica em uns 695 B.
docs · blocos · performance · código
o problema#
biblioteca de componentes geralmente é uma dependência: você instala, importa e torce pra ela continuar fazendo o que você precisa. quando precisa mudar um detalhe, é briga com a API ou com o CSS.
e quase toda biblioteca diz que é "leve". quase nunca diz quanto.
eu queria o contrário:
- componente acessível que vira código meu, não dependência;
- o peso de cada um medido, não chutado;
- o mínimo de JavaScript possível no navegador.
a ideia#
três apostas simples:
- registry em vez de pacote. o cd/ui não vai pro
node_modules. um comando copia o arquivo do componente pro seu projeto, e dali pra frente o código é seu. - medir no build. o tamanho vem do esbuild, não de estimativa, e aparece em cada página das docs.
- servidor primeiro. componente sem estado é Server Component e não manda JS.
"use client"só entra quando tem estado ou evento.
como fica#
não precisa de shadcn init. você cria um components.json mínimo:
{
"style": "new-york",
"tailwind": { "css": "src/app/globals.css", "baseColor": "neutral" },
"aliases": { "components": "@/components", "utils": "@/lib/utils" }
}
e um comando só instala o tema, o utils e todos os componentes:
npx shadcn@latest add https://cd-ui.vercel.app/r/all.json
os blocos têm o all-blocks.json. dá pra instalar um item avulso também, pela URL ou registrando o namespace no components.json:
{ "registries": { "@cd": "https://cd-ui.vercel.app/r/{name}.json" } }
npx shadcn@latest add @cd/button @cd/dialog
as dependências (Base UI, e Zod quando precisa) e os outros componentes que ele usa vêm junto. e a home já mostra a ideia do projeto.


cada componente tem a sua página, com preview, código, API, teclas e notas de acessibilidade. o selo de tamanho e o selo client/server saem da medição do build.


além dos componentes, o cd/ui tem 15 blocos: telas inteiras prontas (5 de autenticação, 7 de marketing e 3 de app, tipo login, hero, preços, FAQ e configurações), montadas só com os componentes da biblioteca. um bloco instala com um comando e já leva junto os componentes que usa:
npx shadcn@latest add https://cd-ui.vercel.app/r/login-01.json
a página de cada bloco mostra o preview em desktop, tablet e celular, o código, e abre em tela cheia. as docs e os blocos tão em português e em inglês.


como funciona por trás#
o repositório tem duas fontes de verdade: os arquivos em src/registry/cd/ui/*.tsx (o que é distribuído) e o src/docs/catalog.json (nome, título, categoria e descrição de cada componente). os blocos seguem o mesmo esquema, em src/registry/cd/blocks e src/blocks/catalog.json. o build lê tudo isso e gera o que o site e a CLI precisam.
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 roda o registry:build (os dois scripts e o shadcn build) e só depois o next build.
ninguém escreve o registry na mão#
o scripts/build-registry.mjs descobre as dependências lendo os imports do próprio arquivo:
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]);
}
o que não é @/ nem react vira dependência npm. o que é @/registry/cd/ui/outro vira dependência de registry (a URL do outro componente). o item theme copia os tokens do globals.css, os blocos :root e .dark.
no fim: se o componente passa a importar outra coisa, o registry já fica sabendo. não tem como esquecer de atualizar lista nenhuma. com os blocos é igual: os componentes que eles importam viram dependências de registry, então instalar um bloco instala tudo que ele usa.
o que aprendi instalando num projeto de verdade#
quando instalei o cd/ui num projeto de fora, apareceram quatro problemas que o meu próprio site não mostrava:
- o
cnera o do shadcn. sem uma URL proutils, o shadcn usa o item embutido dele, que instalaexport { cn } from "cn". agora o cd/ui entrega o próprio itemutils(clsx + tailwind-merge) e todo componente depende dele. - Server Component não conseguia chamar o
buttonVariants. ele ficava nobutton.tsx, que é"use client", e uma função exportada de um módulo client não roda no servidor. mudei pra umbutton-variants.ts, sem"use client", e obutton.tsxsó reexporta. no registry, os arquivos irmãos que o componente importa e que não tão no catálogo vão junto no mesmo item. - sem
components.json, oaddnão faz o que parece. num app vazio ele joga os arquivos na pasta atual e não mexe no CSS, e o modo interativo oferece uminitque traz os padrões do shadcn (tw-animate, os tokens dele). por isso ocomponents.jsonmínimo é o passo que importa. - os blocos importam pelo caminho do registry. eles usam
@/registry/cd/ui/button, e a CLI troca esse caminho pelo alias docomponents.jsonde quem instala.
como o peso é medido#
o scripts/metrics.mjs empacota cada componente com esbuild e mede em gzip:
const result = await build({
entryPoints: [file],
bundle: true,
minify: true,
write: false,
format: "esm",
// bibliotecas ficam de fora; o código do cd/ui entra
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, /* … */ };
as bibliotecas ficam de fora de propósito: o Base UI e o Zod já tão no seu projeto ou são compartilhados. o número é o código que entra no seu projeto além delas, que é a parte que o cd/ui controla.
o mesmo script marca cada componente como client ou servidor, só olhando se o arquivo começa com "use client":
client: /^["']use client["']/.test(source),
os números do site saem dessa medição. os 32 componentes, do menor pro maior:
| componente | gzip | roda onde |
|---|---|---|
| skeleton | 234 B | servidor |
| kbd | 282 B | servidor |
| separator | 284 B | servidor |
| card | 377 B | servidor |
| avatar | 383 B | client |
| spinner | 398 B | servidor |
| textarea | 451 B | client |
| field | 451 B | client |
| badge | 453 B | servidor |
| progress | 465 B | client |
| popover | 478 B | client |
| radio-group | 522 B | client |
| input | 525 B | client |
| otp-input | 539 B | client |
| form | 553 B | client |
| auth-shell | 573 B | servidor |
| tabs | 613 B | client |
| switch | 660 B | client |
| slider | 667 B | client |
| checkbox | 688 B | client |
| alert | 776 B | servidor |
| tooltip | 794 B | client |
| pricing-toggle | 885 B | client |
| dialog | 898 B | client |
| accordion | 912 B | client |
| table | 930 B | servidor |
| dropdown-menu | 1,0 kB | client |
| logo | 1,1 kB | client |
| button | 1,2 kB | client |
| select | 1,2 kB | client |
| toast | 1,3 kB | client |
| password-input | 1,7 kB | client |
média de uns 695 B. 9 dos 32 (alert, auth-shell, badge, card, kbd, separator, skeleton, spinner e table) não mandam JS nenhum.
formulário com Zod sem carregar o Zod inteiro#
o Form importa só o núcleo do Zod, o zod/v4/core:
import { type $ZodObject, flattenError, type output, safeParse } from "zod/v4/core";
assim ele aceita schema de zod e de zod/mini. você passa o schema, dá name pros Field, e cada campo valida a própria chave quando você sai dele:
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);
};
no envio, roda o schema inteiro. se falhar, os erros vão pro FieldError de cada campo. se passar, o onSubmit recebe os dados já convertidos e com o tipo do schema:
const schema = z.object({
email: z.email("e-mail inválido"),
idade: z.coerce.number().min(18, "precisa ter 18+"),
})
<Form schema={schema} onSubmit={(values) => {
values.idade // number, não string
}}>
<Field name="email">…</Field>
<Field name="idade">…</Field>
</Form>
decisões#
copiar em vez de depender. um componente é um arquivo seu. se o botão precisa de outra variante, você edita o arquivo e pronto. o custo é que atualizar depois fica por sua conta.
Base UI por baixo. foco, teclado e aria vêm do Base UI. o cd/ui coloca o visual e as variantes por cima, e cada página de componente lista as teclas e as notas de acessibilidade.
importar só o que usa. cada componente importa só a parte do Base UI que usa (@base-ui/react/dialog), nunca o pacote inteiro. animação, textarea que cresce e spinner são CSS.
movimento com motivo. o botão afunda 2% em 80 ms quando você clica. no geral é de 80 a 240 ms, curvas fortes, só transform e opacity, e todos respeitam o prefers-reduced-motion. a busca (⌘K) abre e fecha sem animação nenhuma: quem usa atalho abre isso centenas de vezes por dia.
o prompt é a logo. o cabeçalho das docs mostra cd/ui ~/docs/button $ com um cursor piscando: o texto é a logo e ainda diz em que página você tá.
o nome do componente é validado. o nome vira caminho de arquivo e URL, então o build só aceita letra minúscula, número e hífen (/^[a-z][a-z0-9-]*$/).
o que mudou depois#
- performance. trocar de página nas docs parecia lento. olhando o output do build, toda rota tava dinâmica (ƒ,
no-store) porque o layout raiz lia o idioma de um cookie. o idioma virou segmento de rota ([locale]), lido dos params: agora toda página é estática, com cache de CDN de 1 ano e navegação com prefetch. carregar a busca sob demanda só economizou uns 4 KB (o Base UI já tava nos chunks compartilhados). o ganho de verdade foi a página estática. - tokens de movimento e de raio. as durações tavam fixas em cada componente (uns 20 arquivos). agora são
--cd-duration-instant/fast/base/slow(80/120/160/240 ms),--cd-ease-oute--cd-scale-enter, com um bloco só deprefers-reduced-motione a utilitycd-popuppros 5 popups. sobrescrever um token no:rootajusta tudo, ou um componente só com[--cd-duration-base:300ms]. tirei o tw-animate-css, que tava importado e ninguém usava. a escala de raio antiga (6/8/10/14/18 px) deixava tudo com a mesma cara: agora é uma base de 12 px com 4/6/9/12/18/24, mais--radius-buttone--radius-field. - o Select travava. o
alignItemWithTriggerpadrão do Base UI fazia o popup pular por cima do gatilho dependendo do item selecionado. desliguei por padrão (continua sendo uma prop), com o check na direita e o destaque visível. - peças com cara própria. accordion "prompt tree" (marcador ▸ que gira e linha da marca no item aberto; o chevron antigo continua), alert como linha de log de terminal (
[warn] disk almost full - 14 GB left,role="alert"só em warn e error), botãokeyinspirado no Kbd, tooltip estilo Material com o wrapperTip, SwitchRow (a linha inteira é clicável), tabela com numérico, densidade e cabeçalho fixo, Logo, e PasswordInput, OtpInput, AuthShell e PricingToggle tirados dos blocos.
status e próximos passos#
o cd/ui tá na v0.2, com 32 componentes, 15 blocos, tema claro e escuro e o site de docs no ar, em português e inglês. é open source: licença MIT, guia de contribuição e CI rodando lint, tipos e build em todo pull request. sendo honesto sobre a medição: ela conta o código do cd/ui, não o Base UI nem o Zod que vêm junto.
pra adicionar um componente o caminho é curto: o arquivo em src/registry/cd/ui, a entrada no catálogo, os exemplos e a documentação, e o bun run registry:build gera o resto.
