gpr-ui
v0.4.7
Published
GPR Design System — componentes e tokens compartilhados entre os produtos da GPR (Brand Book 2026).
Maintainers
Readme
gpr-ui
Sistema de componentes e tokens compartilhados entre os produtos da GPR. Materializa o Brand Book 2026 e o UI Kit 2026 em uma biblioteca React pronta para consumo em qualquer produto.
Sumário
- O que tem aqui
- Passo a passo — projeto novo do zero
- Setup em projeto existente
- API dos componentes
- Tokens
- Dark mode
- Regras do design system
- Desenvolvimento local da lib
O que tem aqui
Primitivos: Button (com loading e asChild), Input, PasswordInput, Label, Badge, StatusBadge, Card (+ subcomponentes).
Overlays: Dialog (genérico) e ConfirmDialog (com isLoading + variantes).
Formulário: Select (seleção única, wrapper do Radix), MultiSelect (seleção múltipla com chips, busca opcional e ações rápidas).
Dados: Table (primitivos) e DataTable (opinado, com loading/empty/sort/paginação).
Layout: AppShell (com GprLogo default), SidebarItem, SidebarSection, useSidebar, UserMenu, ThemeToggle (+ useTheme), GprLogo, PageHeader, AuthLayout.
Preset Tailwind com toda a paleta GPR, tipografia Inter e escalas de radius/elevation/z-index/motion.
Tokens semânticos em CSS variables (light + dark) — --primary, --foreground, --sidebar, etc.
Helpers: cn() e Slot.
Passo a passo — projeto novo do zero
Comece um produto GPR novo em ~5 minutos.
1. Criar o projeto
pnpm create vite@latest meu-app -- --template react-ts
cd meu-app
pnpm install2. Instalar Tailwind 3 + gpr-ui
pnpm add -D tailwindcss@^3 postcss autoprefixer
pnpm dlx tailwindcss@^3 init -p
pnpm add gpr-ui
# Se for usar roteamento (exemplo do passo 6):
pnpm add react-router-domImportante: o preset foi escrito para Tailwind 3. Em Tailwind 4 o
tailwind.config.jse a sintaxe de presets mudam — mantenha a v3 por ora. Note o@^3também nopnpm dlxpra garantir que o binário doinitseja da v3.Se
pnpm add gpr-uifalhar (pacote não publicado no npm público ainda), use o fallback local:// package.json do projeto consumidor { "dependencies": { "gpr-ui": "file:../caminho/para/gpr-ui" } }Ajuste o path e rode
pnpm install. Veja também a seção Desenvolvimento local.
3. Configurar tailwind.config.js
Substitua o conteúdo gerado por:
import gprPreset from 'gpr-ui/tailwind-preset'
export default {
presets: [gprPreset],
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
'./node_modules/gpr-ui/dist/**/*.{js,cjs}',
],
}A última linha é crítica — sem ela, Tailwind faz purge das classes usadas dentro dos componentes da lib.
4. Importar os estilos (uma única vez, no entry)
// src/main.tsx
import 'gpr-ui/styles.css' // tokens CSS + base GPR
import './index.css' // seu CSS com @tailwind directives
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)E no seu src/index.css:
@tailwind base;
@tailwind components;
@tailwind utilities;5. Primeiro componente
// src/App.tsx
import { Button, Card, CardHeader, CardTitle, CardContent } from 'gpr-ui'
export default function App() {
return (
<main className="min-h-screen bg-background text-foreground p-8">
<Card className="max-w-md mx-auto">
<CardHeader>
<CardTitle>Olá, GPR</CardTitle>
</CardHeader>
<CardContent className="flex gap-2">
<Button>Salvar</Button>
<Button variant="outline">Cancelar</Button>
</CardContent>
</Card>
</main>
)
}pnpm dev6. Montar o app com layout completo (quando for produto real)
import { Link, useLocation } from 'react-router-dom'
import {
AppShell,
SidebarItem,
SidebarSection,
UserMenu,
ThemeToggle,
} from 'gpr-ui'
import { HomeIcon, SettingsIcon } from 'lucide-react'
export function App({ children }) {
const { pathname } = useLocation()
return (
<AppShell
// logo opcional — default é <GprLogo /> (wordmark oficial inline)
navbarRight={
<>
<ThemeToggle />
<UserMenu
name="Fábio Garcia"
email="[email protected]"
onLogout={() => { /* ... */ }}
/>
</>
}
sidebar={
<>
<SidebarItem asChild icon={HomeIcon} active={pathname === '/'}>
<Link to="/">Início</Link>
</SidebarItem>
<SidebarSection label="Configurações">
<SidebarItem
asChild
icon={SettingsIcon}
active={pathname.startsWith('/settings')}
>
<Link to="/settings">Preferências</Link>
</SidebarItem>
</SidebarSection>
</>
}
>
{children}
</AppShell>
)
}7. Checklist final
- [ ]
gpr-ui/styles.cssimportado uma vez no entry - [ ]
presets: [gprPreset]notailwind.config.js - [ ]
./node_modules/gpr-ui/dist/**/*.{js,cjs}nocontent - [ ] Peer deps instaladas:
react ≥18,react-dom ≥18,tailwindcss ≥3 - [ ] Se usa dark mode:
ThemeTogglemontado ou adicionar classe.darkno<html>manualmente
Setup em projeto existente
Se você já tem React + Tailwind configurado, pule pros passos 2→4 acima:
pnpm add gpr-ui// tailwind.config.js
import gprPreset from 'gpr-ui/tailwind-preset'
export default {
presets: [gprPreset],
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
'./node_modules/gpr-ui/dist/**/*.{js,cjs}',
],
}// entry
import 'gpr-ui/styles.css'Peer deps: react ≥18, react-dom ≥18, tailwindcss ≥3.
API dos componentes
Button
<Button>Salvar</Button>
<Button variant="outline" size="sm">Cancelar</Button>
<Button variant="destructive">Excluir</Button>
// loading: desabilita + spinner
<Button loading={mutation.isPending}>Salvar</Button>
// asChild: aplica as classes no filho único (ideal pra Link)
<Button asChild>
<Link to="/planos">Ver planos</Link>
</Button>| Prop | Valores |
|---|---|
| variant | default • destructive • outline • secondary • ghost • link |
| size | sm (32px) • default (36px) • lg (40px) • icon (36×36) |
| loading | boolean — prepende spinner e desabilita |
| asChild | boolean — clona o filho aplicando classes |
Input / Label
Altura universal de 44px. aria-invalid="true" aciona ring destructive.
<Label htmlFor="email">E-mail</Label>
<Input id="email" type="email" placeholder="[email protected]" />PasswordInput
Mesmo visual do Input, com botão olho pra alternar visibilidade da senha. Herda todos os comportamentos do Input (aria-invalid, disabled, altura 44px).
<Label htmlFor="password">Senha</Label>
<PasswordInput id="password" placeholder="••••••••" />
// Com validação
<PasswordInput aria-invalid={hasError} />
// Disabled (desabilita o toggle junto)
<PasswordInput disabled />
// Labels de acessibilidade customizáveis (default: "Mostrar senha" / "Ocultar senha")
<PasswordInput showLabel="Show password" hideLabel="Hide password" />Badge
<Badge>Ativo</Badge>
<Badge variant="success">Verificado</Badge>
<Badge variant="warning">Atenção</Badge>
<Badge variant="destructive">Expirado</Badge>Variantes: default, secondary, destructive, outline, success, warning.
StatusBadge
Variante especializada do Badge para estado de um dado (ativo/pendente/falhou). Tem ponto colorido indicador e aceita ícone customizado (ex.: spinner pra "processando").
<StatusBadge tone="success">Ativo</StatusBadge>
<StatusBadge tone="destructive">Falhou</StatusBadge>
<StatusBadge tone="warning">Pendente</StatusBadge>
<StatusBadge tone="info">Novo</StatusBadge>
<StatusBadge tone="neutral">Arquivado</StatusBadge>
// Com ícone no lugar do dot
<StatusBadge tone="info" icon={<Loader2 className="w-3 h-3 animate-spin" />}>
Processando
</StatusBadge>
// Só texto
<StatusBadge tone="neutral" hideIndicator>Rascunho</StatusBadge>Tons: success, destructive, warning, info, neutral.
Card
Composição via subcomponentes — nunca adicione mt-* manual entre eles.
<Card>
<CardHeader>
<CardTitle>Faturamento</CardTitle>
<CardDescription>Último trimestre</CardDescription>
</CardHeader>
<CardContent>R$ 48.000</CardContent>
<CardFooter>
<Button variant="outline">Ver detalhes</Button>
</CardFooter>
</Card>Dialog
Wrapper do Radix com tokens do DS. Use para qualquer modal que não seja de confirmação (pra confirmação, prefira ConfirmDialog abaixo).
<Dialog>
<DialogTrigger asChild>
<Button>Editar cliente</Button>
</DialogTrigger>
<DialogContent size="md">
<DialogHeader>
<DialogTitle>Editar cliente</DialogTitle>
<DialogDescription>Atualize os dados abaixo.</DialogDescription>
</DialogHeader>
{/* form aqui */}
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancelar</Button>
</DialogClose>
<Button onClick={save}>Salvar</Button>
</DialogFooter>
</DialogContent>
</Dialog>Props de DialogContent: size (sm • md • lg • xl), hideCloseButton.
ConfirmDialog
<ConfirmDialog
open={confirmOpen}
title="Apagar cliente?"
message="Esta ação é irreversível."
variant="danger"
isLoading={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
onCancel={() => setConfirmOpen(false)}
/>Variantes: danger, warning, info.
Select
Wrapper do Radix Select com tokens GPR. aria-invalid aciona anel destructive.
<Select value={value} onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="Escolha um plano" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Planos</SelectLabel>
<SelectItem value="basic">Básico</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
// Altura reduzida (inputs inline em listagens)
<SelectTrigger size="sm">...</SelectTrigger>MultiSelect
Seleção múltipla com chips removíveis no trigger, busca opcional e ações rápidas. Construído sobre Radix Popover.
const [selected, setSelected] = useState<string[]>([])
<MultiSelect
options={[
{ value: 'sp', label: 'São Paulo' },
{ value: 'rj', label: 'Rio de Janeiro' },
{ value: 'mg', label: 'Minas Gerais' },
{ value: 'rs', label: 'Rio Grande do Sul' },
]}
value={selected}
onValueChange={setSelected}
placeholder="Selecione estados..."
/>
// Com busca (default: desligada)
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
searchable
/>
// Customizando ações / tamanho
<MultiSelect
options={options}
value={selected}
onValueChange={setSelected}
size="sm"
maxChips={5} // default: 3
showSelectAll={false}
showClear
emptyMessage="Nenhuma opção disponível."
/>| Prop | Tipo | Default | Descrição |
|---|---|---|---|
| options | MultiSelectOption[] | — | { value, label, disabled? }[] |
| value | string[] | — | Valores selecionados (controlado) |
| onValueChange | (value: string[]) => void | — | Callback de mudança |
| placeholder | string | "Selecione..." | Texto quando nada selecionado |
| searchable | boolean | false | Mostra campo de busca |
| searchPlaceholder | string | "Buscar..." | Placeholder da busca |
| showSelectAll | boolean | true | Botão "Selecionar todos" |
| showClear | boolean | true | Botão "Limpar" |
| maxChips | number | 3 | Chips no trigger antes de virar "+N" |
| size | 'default' \| 'sm' | 'default' | Altura do trigger |
| emptyMessage | ReactNode | "Nenhum resultado." | Texto quando busca vazia |
Table (primitivos)
Sem lógica — só apresentação. Use quando precisar de controle total (colunas expansíveis, seleção múltipla, virtualização).
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>E-mail</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Fábio</TableCell>
<TableCell>[email protected]</TableCell>
</TableRow>
</TableBody>
</Table>DataTable
Listagem opinada com loading, empty state, sort e paginação prontos. Cobre 90% dos casos.
type Client = { id: string; name: string; status: string }
const columns: DataTableColumn<Client>[] = [
{ key: 'name', header: 'Nome', sortable: true },
{
key: 'status',
header: 'Status',
render: (c) => <StatusBadge tone="success">{c.status}</StatusBadge>,
},
{ key: 'actions', header: '', align: 'right', render: (c) => (
<Button size="sm" variant="outline">Abrir</Button>
)},
]
<DataTable
columns={columns}
data={clients}
loading={query.isPending}
emptyMessage="Nenhum cliente cadastrado."
rowKey={(c) => c.id}
onRowClick={(c) => navigate(`/clientes/${c.id}`)}
// Sort controlado (opcional)
sort={sort}
onSortChange={setSort}
// Paginação controlada (opcional)
pagination={{ page, pageSize: 20, total: query.data?.total ?? 0 }}
onPageChange={setPage}
/>Props principais:
| Prop | Descrição |
|---|---|
| columns | Array de DataTableColumn<T> com key, header, render?, sortable?, align? |
| data | Array de itens |
| loading | Mostra skeleton (N linhas = loadingRows, default 5) |
| emptyMessage | Mensagem quando data é vazio |
| rowKey | (item, i) => key — default usa o índice |
| onRowClick | Callback na linha (cursor pointer automático) |
| sort + onSortChange | Estado controlado de ordenação |
| pagination + onPageChange | Controles inferiores (só aparecem se total > pageSize) |
AppShell + Sidebar
Layout completo com navbar (77px) + sidebar (220/76px) + drawer mobile. Persiste estado colapsado em cookie por 7 dias.
<AppShell
// logo default: <GprLogo /> — passe algo só se quiser sobrescrever
navbarRight={<>{/* ThemeToggle, UserMenu, etc */}</>}
sidebar={<>{/* SidebarItem, SidebarSection */}</>}
defaultSidebarOpen={true} // opcional
cookieName="meuapp.sidebar_open" // opcional
>
{children}
</AppShell>SidebarItem — item clicável da nav.
// Com router Link (Slot pattern)
<SidebarItem asChild icon={HomeIcon} active={isHome}>
<Link to="/">Início</Link>
</SidebarItem>
// Botão com onClick
<SidebarItem icon={BellIcon} onClick={() => openNotifications()}>
Notificações
</SidebarItem>SidebarSection — agrupa items com label (vira divisor quando colapsado).
<SidebarSection label="Administração">
<SidebarItem ...>Usuários</SidebarItem>
<SidebarItem ...>Permissões</SidebarItem>
</SidebarSection>useSidebar() — hook pra ler/controlar estado.
const { expanded, toggle, mobileOpen, setMobileOpen } = useSidebar()UserMenu
Avatar circular com iniciais. Clique abre dropdown com info + logout.
<UserMenu
name="Fábio Garcia"
email="[email protected]"
onLogout={() => logoutFn()}
extraActions={/* opcional — slot antes do logout pra Perfil/Settings */}
/>PageHeader
Título de página com descrição opcional e ação alinhada à direita. Use no topo de páginas inteiras (pra títulos dentro de Card, use CardHeader).
import { PageHeader, Button } from 'gpr-ui'
import { PlusIcon } from 'lucide-react'
<PageHeader
title="Usuários"
description="Gerencie usuários e seus acessos aos produtos."
action={
<Button onClick={openCreate}>
<PlusIcon className="h-4 w-4" />
Criar usuário
</Button>
}
/>
// Só título e ação (sem descrição)
<PageHeader
title={user.name}
action={<Button variant="secondary">Editar</Button>}
/>
// Sem linha decorativa
<PageHeader title="Dashboard" divider={false} />
// Com wrapper externo (ex.: back button) — anule o mb-8 padrão:
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="icon" asChild>
<Link to="/users"><ArrowLeftIcon className="h-5 w-5" /></Link>
</Button>
<PageHeader title={user.name} className="mb-0" />
</div>| Prop | Tipo | Default | Descrição |
|---|---|---|---|
| title | ReactNode | — | Título da página (h1, text-display-md/lg) |
| description | ReactNode | — | Subtítulo (text-sm text-muted-foreground) |
| action | ReactNode | — | Conteúdo alinhado à direita (normalmente Button) |
| divider | boolean | true | Linha decorativa de 48px entre título e descrição |
| className | string | — | Override das classes (útil pra anular mb-8) |
AuthLayout
Shell visual pras páginas de autenticação (login, registro, recuperar senha). Fundo gradiente GPR, watermark do logo atrás e card branco centralizado. Não tem lógica — toda validação, submit, auth e roteamento fica no consumidor.
import { AuthLayout, Label, Input, PasswordInput, Button } from 'gpr-ui'
import { useForm } from 'react-hook-form'
export function LoginPage() {
const { register, handleSubmit, formState: { errors } } = useForm()
return (
<AuthLayout
productName="Authenticator"
title="Acesse sua conta"
description="Autenticação restrita a administradores autorizados"
footer="© GPR Authenticator — Uso interno autorizado"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-mail</Label>
<Input id="email" type="email" {...register('email')} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Senha</Label>
<PasswordInput id="password" {...register('password')} />
</div>
<Button type="submit" size="lg" className="w-full" loading={isLoading}>
Entrar
</Button>
</form>
</AuthLayout>
)
}Sobrescrever o logo (quando o produto tem identidade visual própria):
<AuthLayout
logo={<img src="/plan-100-logo.svg" alt="Plan 100" className="h-10" />}
productName="Plan 100"
title="Entrar"
>
{/* form */}
</AuthLayout>| Prop | Tipo | Default | Descrição |
|---|---|---|---|
| children | ReactNode | — | Corpo do card (o <form> do consumidor) |
| logo | ReactNode | <GprLogo height={40} /> | Logo no topo do card |
| productName | string | — | Rótulo discreto abaixo do logo (text-label-sm em gpr-500) |
| title | ReactNode | "Acesse sua conta" | Título principal do card |
| description | ReactNode | "Faça login para acessar o painel" | Subtítulo abaixo do título |
| footer | ReactNode | — | Texto abaixo do card (copyright, links) |
| className | string | — | Classes extras no wrapper raiz |
Todas as props (menos children) são opcionais — se omitir, simplesmente não renderiza.
GprLogo
Wordmark oficial da GPR inline (não depende de arquivo em /public). É o logo default do AppShell.
// Uso standalone (login, splash, footer, etc.)
<GprLogo /> // 32px, cor primary
<GprLogo height={48} /> // tamanho customizado
<GprLogo className="text-white" /> // inverter cor (fundo escuro)Aceita todas as props de <svg> (exceto viewBox e xmlns). A cor usa currentColor — por isso className="text-*" funciona.
Altura default 32px; largura é sempre proporcional (ratio ≈ 1.72). Consumidores não devem ter mais gpr.svg no /public — importam daqui.
ThemeToggle
Botão pronto pra alternar light/dark. Persiste em localStorage, respeita prefers-color-scheme na primeira visita.
<ThemeToggle />Se precisar controlar o tema fora do botão:
import { useTheme } from 'gpr-ui'
const { theme, toggle, setTheme } = useTheme()Helpers
import { cn, Slot } from 'gpr-ui'
// cn — merge de classes com tailwind-merge
<div className={cn('p-4', isActive && 'bg-primary', className)} />
// Slot — clona o filho aplicando props/classes (usado internamente pelo asChild)Tokens semânticos
Cores: bg-primary, bg-secondary, bg-muted, bg-accent, bg-destructive, bg-card, bg-popover, bg-sidebar, text-foreground, text-muted-foreground, border-border, border-input, ring, bg-success-*, bg-warning-*, bg-critical-*, e todas as escalas gpr-{100..900} / success-{100..900} / warning-{100..900} / critical-{100..900}.
Tipografia: text-display-lg/md, text-title-lg/md/sm, text-body-lg/md/sm, text-label-md/sm, text-caption.
Radius: rounded-xs (4px) • rounded-sm (8px) • rounded-md (10px) • rounded-lg (12px) • rounded-xl (12px) • rounded-2xl (16px).
Sombras: shadow-elevation-1 a shadow-elevation-4.
Motion: duration-fast (150ms) • duration-base (200ms) • duration-slow (300ms).
Z-index: z-base, z-raised, z-sticky, z-overlay, z-sidebar, z-modal, z-dropdown, z-toast.
Dark mode
Ativado adicionando a classe .dark no <html>. O ThemeToggle e o hook useTheme() fazem isso automaticamente. Se preferir controle manual:
document.documentElement.classList.toggle('dark')A cor --primary (#008DFF) não muda entre temas — só os neutros e superfícies (regra do Brand Book cap. DS-06).
Regras do design system
- Neutros dominam, primary é acento — nunca use
bg-primarycomo fundo de página. - Tokens semânticos, nunca hex —
bg-primary, não#008DFF. - Uma ação principal por tela — apenas um
Button variant="default". - Active ≠ Hover — active muda cor+peso; hover muda bg.
- Dimensões fixas — Input 44px, Button default 36px, SidebarItem 40px.
- Focus-visible global — já configurado pelo
styles.css.
Desenvolvimento local
pnpm install
pnpm build # single build
pnpm dev # watch mode
pnpm typecheckPara testar num projeto consumidor sem publicar:
{
"dependencies": { "gpr-ui": "file:../gpr-ui" }
}Ou via link simbólico:
# no gpr-ui
pnpm link --global
# no projeto consumidor
pnpm link --global gpr-uiPublicando
npm version patch # ou minor / major
pnpm publish --access publicChangelog
- 0.3.x —
Dialoggenérico,Select,StatusBadge,Table(primitivos) eDataTable(com loading/empty/sort/paginação). - 0.2.0 —
AppShell+ Sidebar completo,UserMenu,ThemeToggle. Button ganhouloading+asChild. ConfirmDialog ganhouisLoading. HelperSlotexportado. - 0.1.0 — Primitivos iniciais (Button, Input, Label, Badge, Card, ConfirmDialog), preset Tailwind, tokens CSS.
Licença
Uso interno GPR.
