@rivocode/ui
v0.14.0
Published
Design system da RivoCode: 91 componentes React sobre a Base UI, com tokens em tres camadas, dois temas e duas densidades.
Downloads
2,315
Maintainers
Readme
@rivocode/ui
O design system da RivoCode. Componentes acessíveis sobre a Base UI, estilo autoral em Tailwind v4, e tokens white-label: nenhum componente sabe qual é a cor da marca, ele pergunta ao tema.
Isso é o que permite a mesma biblioteca vestir a RivoCode num projeto e o cliente X em outro, sem editar componente nenhum.
Instalação
npm install @rivocode/ui lucide-react # ou pnpm add, yarn add, bun addPúblico no npm, sob licença MIT. Não precisa de token nem de .npmrc.
O lucide-react vai na mesma linha porque os componentes importam ícone direto
dele. O npm resolve esse par sozinho; o pnpm e o yarn não, e sem ele a
Sidebar, a Pagination e o DatePicker quebram em tempo de execução.
O Tailwind entra como dependência de desenvolvimento:
npm install -D tailwindcss @tailwindcss/viteReact 19, React DOM 19 e Tailwind 4 são dependências de par, ou seja, quem manda na versão é o projeto consumidor.
Ligar o Tailwind no build
Instalar o plugin não basta, ele precisa entrar na lista. Sem isso o build passa sem erro e gera um CSS sem uma única classe da biblioteca:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})As duas linhas de CSS
No arquivo de CSS do projeto:
@import "tailwindcss";
@import "@rivocode/ui/preset";
@source '../node_modules/@rivocode/ui/dist';A linha @source não é opcional e é a que mais quebra. Sem ela, o Tailwind do
projeto não varre os componentes da biblioteca, não gera as classes que eles
usam, e tudo aparece sem estilo nenhum, silenciosamente. Ajuste o caminho
relativo conforme a pasta do seu arquivo de CSS.
O preset traz os tokens, os dois temas e as fontes da marca. Se o projeto já
tem tipografia própria, importe apenas os arquivos de token e escreva o seu
tema, como descrito em "Tema de cliente".
O Provider
import { RivoProvider, Button } from "@rivocode/ui";
export function App() {
return (
<RivoProvider theme="rivocode-dark" density="comfortable">
<Button>Acao primaria</Button>
</RivoProvider>
);
}| Prop | Valores | Para que serve |
| --------- | ------------------------------------------- | ------------------------------------------------------------------ |
| theme | rivocode-dark, rivocode-light, system | system segue a preferência do sistema operacional |
| density | comfortable, compact | compact encolhe a altura de todo controle, para tela de operação |
| scope | global, local | global veste a página inteira. local veste só esta árvore |
| dir | ltr, rtl | em rtl a Base UI espelha o que depende de lado |
Use scope="local" quando o design system entra num projeto que já existe e não
pode vazar estilo para o resto da página. Nesse modo o Provider também cria um
container próprio para diálogo, menu e dica, que renderizam fora da árvore e
sairiam sem tema se ficassem soltos no fim do documento.
Vocabulário para o seu layout
O preset expõe os tokens como utilitários do Tailwind, então o layout que você escreve fala a mesma língua dos componentes:
| Família | Utilitários |
| ------------- | ---------------------------------------------------------------------------------------- |
| Superfícies | bg-bg, bg-surface, bg-surface-raised, bg-overlay |
| Texto | text-fg, text-fg-muted, text-fg-subtle, text-fg-disabled |
| Acento | bg-accent, text-accent-fg, text-accent-text, bg-accent-subtle |
| Linhas e foco | border-border, border-border-strong, ring-ring |
| Estados | bg-success, text-success-text, bg-danger-subtle, e o mesmo para warning e info |
| Forma | rounded-sm, rounded-md, rounded-lg, rounded-xl, rounded-pill |
| Tipografia | text-xs a text-3xl, font-sans, font-display, font-mono |
Preenchimento e texto são tokens diferentes de propósito. bg-danger é o
vermelho que preenche um botão e recebe text-danger-fg por cima.
text-danger-text é o vermelho que se lê sobre o fundo da página. Nenhuma cor
serve bem para as duas coisas: a que tem contraste como texto não aguenta texto
branco por cima, e vice-versa. Vale o mesmo para o acento.
O catálogo
91 peças. A tabela abaixo não é o índice: ela cobre as mais usadas e diz a diferença entre as que se parecem, que é a parte que costuma faltar. O índice completo, sempre em dia, fica em https://ds.rivocode.com.br/llms.txt.
Ação
| Peça | Para que serve |
| ----------------------- | -------------------------------------------------------------------- |
| Button | cinco variantes, quatro tamanhos, forma em pílula e botão de ícone |
| Toggle, ToggleGroup | botão que fica apertado: alinhamento, modo de exibição, filtro |
| Toolbar | junta os controles numa parada de tabulação só, com setas entre eles |
Campo
| Peça | Para que serve |
| ------------------------------- | -------------------------------------------------------------------------- |
| Field, Input | campo com rótulo, ajuda e erro ligados por acessibilidade |
| Textarea | várias linhas; altura em número de linhas, sem variante de tamanho |
| MaskedInput | CPF, CNPJ, CEP, telefone, data, hora, placa, cartão, dinheiro, molde à mão |
| InputGroup | encosta R$, .com.br ou botão no campo, sem borda dupla |
| Checkbox | caixa de marcar, com o estado misto do "selecionar todos" |
| Radio, RadioGroup | escolha única quando as opções cabem na tela |
| Switch | liga e desliga na hora; o Checkbox só vale ao enviar o formulário |
| Select | escolha única em lista curta e fixa |
| Combobox | escolha em lista longa ou vinda do servidor, com busca e fichas |
| TreeSelect, Tree | escolha dentro de uma árvore; guarda a folha, nunca o pai |
| DatePicker, DateRangePicker | data e período: digita ou escolhe, com rodapé Aplicar opcional |
| Calendar | o mês cru, para quem quer o calendário na própria tela |
| EventCalendar | a agenda: o que acontece, quando e por quanto tempo. O Calendar escolhe uma data; este mostra compromisso no tempo |
Flutuante
| Peça | Para que serve |
| ------------- | ---------------------------------------------------------------------- |
| Dialog | janela modal; no celular encosta embaixo |
| AlertDialog | confirmação sem volta: não fecha com Esc nem com clique fora |
| Sheet | folha que desliza da borda, com gesto de arrastar; é o menu do celular |
| Popover | painel ancorado de conteúdo livre |
| Tooltip | dica, para botão que só tem ícone |
| Menu | menu de ações, com grupos e item destrutivo |
| Toast | aviso que passa, via useToast() |
Navegação
| Peça | Para que serve |
| ------------ | ------------------------------------------------------------------------ |
| Sidebar | barra lateral que encolhe até a coluna de ícones e vira folha no celular |
| Tabs | abas com risco deslizante; rolam de lado quando não cabem |
| Breadcrumb | o caminho, que dobra o meio em reticência quando fica longo |
| Pagination | páginas, com reticência; no celular vira "3 de 12" com as setas |
| Steps | a régua de um formulário em etapas, com useWizard() |
Dado
| Peça | Para que serve |
| ----------- | ---------------------------------------------------------------- |
| Table | tabela semântica, com seleção de linha |
| DataTable | tabela com os três estados de consulta: carregando, erro e vazio |
| Item | a linha de lista: ícone, texto e ação |
| Badge | selo de estado, seis tons |
| Avatar | foto de pessoa, com a inicial por trás |
Estado
| Peça | Para que serve |
| ------------ | ----------------------------------------------------------- |
| Alert | aviso que fica, com o papel de leitor de tela certo por tom |
| Skeleton | marca de lugar enquanto o dado não chegou |
| Spinner | espera sem fim previsto |
| Progress | espera com fim conhecido, que anda para o fim e termina |
| Meter | capacidade em uso, que sobe e desce: cota, limite |
| EmptyState | estado vazio, com descrição e saída obrigatórias |
Estrutura
Card, Separator, RivoProvider, mais:
| Peça | Para que serve |
| ------------- | ---------------------------------------------------------------------- |
| Accordion | seções que se fecham entre si |
| Collapsible | um bloco só, sem moldura e sem coordenação entre irmãos |
| ScrollArea | barra de rolagem própria, para quando a do sistema atrapalha o desenho |
Três coisas que a biblioteca resolve por você e que costumam dar trabalho:
- Portal com tema. Diálogo, menu, seleção e dica renderizam fora da árvore. O Provider cria um container que carrega o tema, então eles nunca aparecem sem estilo, nem no modo escopado.
- Fiação de aviso. Provedor, portal e área de exibição já vivem no Provider.
Você chama
useToast().add({...})e pronto. - Identidade estável do
useToast(). O gerenciador da Base UI devolve objeto novo a cada renderização, e umuseEffectque dependa dele entra em laço infinito. Aqui ele é estável.
O que a biblioteca decide sozinha no celular
Todo componente é pensado em 390px antes do desktop, e algumas decisões estão embutidas em vez de ficarem por sua conta:
- Painel flutuante não encosta na borda da tela.
DialogeAlertDialogencostam embaixo e ocupam a largura toda.Calendarmostra um mês só, mesmo quando você pede dois.DatePickertroca o painel ancorado por folha de baixo.Sidebarvira folha da esquerda.- Dia do calendário tem 44px de alvo, contra 36 no desktop.
Paginationtroca os números pelas setas,Breadcrumbguarda as duas últimas migalhas,Stepsvira uma linha de texto com barra de progresso.
O useTelaEstreita() está exportado, para as decisões que o seu layout também
precisa tomar em JS.
Formulários
Zod e React Hook Form vivem no subcaminho @rivocode/ui/form, com dependências
de par opcionais: quem não usa formulário não carrega nada disso.
npm install react-hook-form zod @hookform/resolversimport { Input, DatePicker, Button } from "@rivocode/ui";
import { Form, FormField, useZodForm, paraDatePicker } from "@rivocode/ui/form";
import { z } from "zod";
const schema = z.object({
email: z.email("Escreva um email válido"),
vencimento: z.date("Escolha a data"),
});
export function EmitirNota() {
const form = useZodForm(schema, { defaultValues: { email: "" } });
return (
<Form form={form} onSubmit={(valores) => console.log(valores)}>
<FormField name="email" label="E-mail" description="Para onde vai a nota">
{(campo) => <Input {...campo} placeholder="[email protected]" />}
</FormField>
<FormField name="vencimento" label="Vencimento">
{(campo) => <DatePicker {...paraDatePicker(campo)} />}
</FormField>
<Button type="submit">Emitir</Button>
</Form>
);
}O FormField não inventa id nenhum: quem liga o rótulo ao controle é o
Field da Base UI, pelo contexto. Por isso todo controle do catálogo passa pelo
Field.Control dela, o DatePicker inclusive.
O controle vem por função, e não por clonagem do filho, porque cada um recebe
valor de um jeito. Para Input e Textarea, espalhar o campo basta. Para os
outros, os adaptadores fazem a ponte: paraDatePicker, paraSelect e
paraCheckbox.
O useZodForm separa o tipo de entrada do de saída. Sem isso um
z.coerce.number() mente sobre o tipo do campo.
Máscara
O molde usa 9 para dígito, A para letra e * para os dois. O resto é
literal, e a máscara põe sozinha.
import { MaskedInput, aplicarMascara, emCentavos } from "@rivocode/ui";
<MaskedInput mask="cnpj" onValueChange={(comPontuacao, cru) => guardar(cru)} />
<MaskedInput mask="moeda" onValueChange={(texto) => guardar(emCentavos(texto))} />
<MaskedInput mask="99-99/9999" />Guarde o valor cru, não o pontuado: a pontuação muda com o tempo e o dado deixa de bater. O dinheiro sai em centavos, para o servidor receber inteiro em vez de ponto flutuante.
Moldes prontos: cpf, cnpj, cep, telefone, data, hora, placa,
cartao e moeda. O telefone troca de molde entre o fixo e o celular sozinho.
Listagem com estados de consulta
O DataTable não conhece React Query, e isso é de propósito: entram três
booleanos, e funciona igual com fetch na mão, com SWR ou com server component.
<DataTable
data={query.data}
isLoading={query.isLoading}
isError={query.isError}
onRetry={query.refetch}
rowKey={(nota) => nota.id}
columns={[
{ key: "numero", header: "Número" },
{ key: "cliente", header: "Cliente" },
{ key: "valor", header: "Valor", align: "right", hideOnMobile: true },
]}
empty={{ title: "Nenhuma nota", description: "Emita a primeira para ela aparecer." }}
/>Erro vence carregando, e vazio só vale depois que a consulta voltou. Sem essa ordem, uma nova busca sobre um erro pisca "nenhum resultado" antes de mostrar o problema.
Gráficos
Recharts vive no subcaminho @rivocode/ui/chart, com dependência de par
opcional: quem não faz gráfico não carrega os 200 kB dela.
npm install rechartsAs peças da Recharts que a biblioteca veste saem pelo mesmo import: sem isso
você teria a moldura e nada para pôr dentro, e teria que acertar a versão da
Recharts na mão. Tooltip e Legend dela ficam de fora de propósito: os nossos
já embrulham os dois, e o nome colidiria com o Tooltip do catálogo.
import {
CartesianGrid,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
Line,
LineChart,
useChartMotion,
XAxis,
YAxis,
type ChartConfig,
} from "@rivocode/ui/chart";
const config = {
emitidas: { label: "Emitidas" },
pagas: { label: "Pagas" },
} satisfies ChartConfig;
export function NotasPorMes({ dados }) {
const movimento = useChartMotion();
return (
<ChartContainer config={config} className="h-64">
<LineChart data={dados}>
<CartesianGrid vertical={false} />
<XAxis dataKey="mes" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent config={config} />} />
<Line dataKey="emitidas" stroke="var(--color-emitidas)" {...movimento} />
<Line dataKey="pagas" stroke="var(--color-pagas)" {...movimento} />
</LineChart>
</ChartContainer>
);
}Três coisas que o ChartContainer resolve:
- A cor da série vira variável com o nome da série.
emitidasnoconfigpublicavar(--color-emitidas), então a linha, a barra e a dica falam do mesmo jeito, e trocar a cor é mexer num lugar só. Sem cor declarada, entra a próxima da paleta na ordem doconfig. A Recharts não lê classe do Tailwind: a ponte tem que ser por variável de CSS. - Eixo, grade e rastro vêm do tema. A Recharts pinta esses três com cor própria, e no tema escuro eles somem.
- A dica é substituída inteira. A da Recharts sai com fundo branco escrito em estilo embutido, e não há classe que corrija estilo embutido.
A paleta são oito cores por tema (--rc-chart-1 a --rc-chart-8), e elas
passam pela guarda de contraste com um mínimo próprio: 3:1 contra a
superfície, que é a regra de objeto gráfico. Cor de série não carrega texto, e
exigir 4,5:1 dela deixaria a paleta inteira escura demais para distinguir.
O useChartMotion() liga a animação à preferência do sistema. O resto do
catálogo resolve isso por token, mas a Recharts interpola em JS e nenhum token a
alcança: sem ele, o único movimento que sobra numa tela com "reduzir
movimento" ligado é justamente o maior deles.
A altura fica com você, por classe: gráfico sem altura definida some, porque o contêiner mede o pai.
Tela de aplicação
<SidebarProvider defaultOpen>
<Sidebar>
<SidebarHeader>RivoCode</SidebarHeader>
<SidebarContent>
<SidebarGroup label="Operação">
<SidebarMenu>
<SidebarMenuItem href="/painel" icon={<LayoutDashboard size={16} />} active>
Painel
</SidebarMenuItem>
<SidebarMenuItem href="/notas" icon={<FileText size={16} />} badge={<Badge>4</Badge>}>
Notas fiscais
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<SidebarInset>
<header>
<SidebarTrigger />
</header>
</SidebarInset>
</SidebarProvider>Fechada quer dizer coisas diferentes em cada largura: na mesa, encolhida até a coluna de ícones, com o nome de cada item virando dica; no celular, fora da tela, e a barra vira a folha da esquerda. O atalho é Ctrl+B, ou Cmd+B no Mac.
Tema de cliente
Copie src/tokens/themes/rivocode-light.css, troque os valores, e rode a
guarda:
bun run check:contrastEla mede todos os pares que carregam texto e falha se algum ficar abaixo de 4,5 para 1, ou de 7 para 1 no texto principal. Ela existe para transformar "acho que está legível" em número.
rivocode-ui check-theme, no seu projeto
A guarda acima roda aqui dentro. O tema que você escreve roda aí, e nenhuma guarda desta pasta o alcança. Para o seu lado da fronteira existe um comando, e ele viaja no pacote:
npx rivocode-ui check-theme src/tema-acme.css
npx rivocode-ui check-theme src/temas/*.css --json # a mesma coisa, para o CI
npx rivocode-ui check-theme acme.theme.ts # o mapa do React NativeEle lê os arquivos que você passar, junta as declarações por seletor de tema, e cobra os 55 papéis obrigatórios. Sai com código 1 se faltar algum, então uma linha no seu pipeline segura a quebra antes do deploy.
E então mede o contraste, com a mesma conta e a mesma tabela de pares da
guarda acima. É por isso que ela existe nesta seção duas vezes: a matemática
mora em um módulo do pacote, e não em scripts/, então o seu tema é medido pelo
código que mede o nosso — 76 pares por tema, com o alfa composto sobre o fundo
em que ele é desenhado antes de medir. Enquanto essa conta ficou fora do pacote,
quem quis medir o próprio tema escreveu 220 linhas no app: os nomes de papel, os
pares, os mínimos e a composição de alfa. A cópia envelheceu calada, com um
compose que não enxergava duas das três sintaxes de alfa e devolvia NaN.
A ordem das duas perguntas não é detalhe: papel faltando primeiro, porque medir o contraste de um papel que não existe cai no valor herdado e devolve um número bonito por acidente. Se falta papel, o comando para ali e não mede.
A extensão diz qual forma de tema você escreveu. .css é a camada 3 do web.
.ts, .mjs e .js é o mapa com light e dark que o RivoProvider do
@rivocode/ui-native recebe — o arquivo que bun run gen:native --tema
escreve. São dois formatos do mesmo tema, e um comando só para os dois: dois
CLIs divergiriam na primeira correção que só um deles recebesse. Quem prefere
medir por código importa checkThemeMap de @rivocode/ui-native/contrast.
A mensagem diz o que acontece na tela, e não só qual token falta. Faltar
--rc-font-sans não é erro de compilação: o tsc passa, o Vite passa, e a
página inteira renderiza na fonte do navegador. Foi assim que a mudança da
0.7.0, que levou --rc-font-* da camada global para dentro do seletor de tema,
chegou calada em quem tinha tema escrito para a 0.6.x. O comando separa as
faltas em duas listas, quebra calada e quebra visível, e avisa quando o papel
que falta nasceu numa versão nova - que é o momento em que dá para
consertar, no upgrade, e não meses depois.
Os três papéis de acabamento (--rc-accent-image, --rc-accent-shadow e
--rc-overlay-filter) são os únicos opcionais e não entram na conta. Os tokens
de forma também não: eles têm valor de :root por baixo.
Desenvolvimento
bun install
bun run check # lint, tipos, guarda de cor, guarda de contraste, testes
bun run shot # gera a vitrine em demo/dist/, de mesa e de celular
bun run serve # abre a vitrine em http://127.0.0.1:4173bun link duplica o React
Ao desenvolver com bun link, o projeto consumidor puxa o React de dentro
desta pasta em vez do dele, e a página quebra com
Cannot read properties of null (reading 'useState'). Não é defeito do pacote:
o pacote publicado não carrega React dentro. É o link.
No vite.config.ts do projeto consumidor:
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: { dedupe: ["react", "react-dom"] },
});Notas
- A Base UI é o pacote
@base-ui/react. O nome antigo,@base-ui-components/react, parou num candidato a lançamento e não deve ser usado. - A publicação é manual e disparada por tag, nunca automática em push. Biblioteca que publica sozinha publica engano.
- O retrato de celular sai de dentro de um iframe, em
demo/celular.html, e não do tamanho da janela: o Chrome no macOS não abre janela abaixo de 500px, e pedir 390 devolvia uma foto cortada em 390 com layout de 500.
Documentação
Cada peça tem também o endereço cru em markdown, para quem lê com agent em vez
de olho: https://ds.rivocode.com.br/componentes/<nome-em-kebab>.md. O índice
fica em /llms.txt, e há uma skill pronta em /skill.
Licença
MIT. Veja LICENSE.
