gpr-ui
v0.5.3
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), PageHeader, AuthLayout.
Telas prontas: LoginPage (tela de login completa) e LoginForm (só o formulário).
Formatação: MaskedInput + MASKS — CPF, CNPJ, telefone, CEP, data e qualquer formato que você escrever.
Marca / Logos: GprLogo (wordmark "GPR"), LogoDark / LogoWhite (logo completo "GPR Educação" — símbolo + wordmark) e LogoIcon (só o símbolo/mandala).
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" />MaskedInput
Um input pra toda formatação do sistema. Em vez de CpfInput,
CnpjInput, TelefoneInput e CepInput, você escreve o formato:
import { MaskedInput, MASKS } from 'gpr-ui'
<MaskedInput mask="000.000.000-00" /> {/* CPF */}
<MaskedInput mask="(00) 00000-0000" /> {/* celular */}
<MaskedInput mask={MASKS.cpfCnpj} /> {/* aceita os dois */}Tokens do formato
| Token | Aceita |
|---|---|
| 0 | dígito (0-9) |
| A | letra (a-z A-Z) |
| * | alfanumérico |
| \ | escapa o próximo caractere (\0 = zero literal) |
Qualquer outro caractere é separador literal.
Pegando o valor
<MaskedInput
mask={MASKS.cpf}
onValueChange={({ masked, raw, complete }) => {
// masked → "123.456.789-00"
// raw → "12345678900" ← o que vai pra API
// complete → true quando a máscara encheu
if (complete) validarCpf(raw)
}}
/>onChange nativo também funciona — event.target.value já vem formatado.
No controlado, value aceita cru ou formatado; o campo normaliza:
<MaskedInput mask={MASKS.cep} value={cep} onValueChange={(v) => setCep(v.raw)} />Várias máscaras no mesmo campo
Passe uma lista e ele escolhe a menor que ainda comporta o que foi digitado — trocando de formato sozinho conforme a pessoa digita:
<MaskedInput mask={['000.000.000-00', '00.000.000/0000-00']} />
// 11 dígitos → 123.456.789-00
// 12 dígitos → 12.345.678/9001Formatos prontos (MASKS)
| Chave | Formato |
|---|---|
| cpf | 000.000.000-00 |
| cnpj | 00.000.000/0000-00 |
| cpfCnpj | CPF ou CNPJ, troca sozinho |
| cep | 00000-000 |
| telefone | fixo ou celular |
| data | 00/00/0000 |
| hora | 00:00 |
| placa | AAA0A00 (Mercosul) |
| cartao | 0000 0000 0000 0000 |
| agencia | 0000-0 |
| Prop | Tipo | Default | Descrição |
|---|---|---|---|
| mask | string \| string[] | — | Obrigatória. Formato, ou lista deles |
| value | string | — | Controlado; aceita cru ou formatado |
| defaultValue | string | '' | Não-controlado; aceita cru ou formatado |
| onValueChange | (v: MaskedValue) => void | — | { masked, raw, complete } |
| onChange | (e) => void | — | Nativo; e.target.value já formatado |
Aceita todo o resto de <input> (id, placeholder, disabled,
aria-invalid…). inputMode="numeric" entra sozinho quando a máscara só
tem dígitos, pra abrir o teclado numérico no celular.
Duas decisões de comportamento
Separador preguiçoso. Digitou 123 no CPF, o campo mostra 123 — não
123.. O ponto entra junto com o 4º dígito. Máscara que emite o separador
na frente trava o backspace: você apaga o ponto, a máscara devolve o ponto,
e a tecla não fez nada.
Backspace em cima de separador apaga o dado anterior. Sem isso a primeira tecla só removeria o ponto (que volta na hora) e seriam necessárias duas teclas pra apagar um caractere.
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>O padding vertical (py-6) mora no <Card>; os subcomponentes cuidam só
do horizontal (px-6). É o que garante respiro simétrico em qualquer
combinação — inclusive card sem CardFooter, que é o caso mais comum.
Pra conteúdo sangrado (tabela ou imagem ocupando a largura toda):
<Card className="py-0">
<CardContent className="p-0">
<DataTable … />
</CardContent>
</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) |
AuthenticatingSplash
Segura a tela enquanto a app decide se existe sessão (troca de ticket de
SSO, validação de token, refresh). Sem ela, o guard de rota roda antes da
resolução terminar e a pessoa vê um flash do /login seguido de um pulo
pro painel.
Não tem props — a frase e o desenho são iguais em todos os produtos, e é isso que faz a entrada parecer o mesmo sistema.
import { AuthenticatingSplash } from 'gpr-ui'
function App() {
const { isAuthenticating } = useAuth()
// Antes de qualquer <Routes> ou guard.
if (isAuthenticating) return <AuthenticatingSplash />
return <Routes>…</Routes>
}O estado fica no produto — e é onde quase todo mundo erra
isAuthenticating precisa nascer true de forma síncrona, no
inicializador do useState:
// CERTO — já vale no primeiro render
const [isAuthenticating, setIsAuthenticating] = useState(() => precisaResolver())
// ERRADO — o guard já redirecionou antes deste efeito rodar
const [isAuthenticating, setIsAuthenticating] = useState(false)
useEffect(() => { setIsAuthenticating(true) }, [])O guard roda no primeiro render, antes de qualquer efeito. Começando em
false, a pessoa é mandada pro /login no meio da resolução e a splash
nunca aparece — ou pisca por um frame.
Desligue com .finally(), para que uma falha também encerre a espera:
trocarTicket()
.catch(() => setSsoError('Não foi possível entrar pelo portal.'))
.finally(() => setIsAuthenticating(false))Sem o .finally, um erro deixa a tela presa no spinner para sempre.
Não use como loading de página, de fetch ou de tabela — para isso há
skeleton (DataTable loading) e Button loading. Esta é só a resolução de
sessão no boot. E não a coloque dentro do AppShell: ela substitui a
árvore inteira, não é conteúdo de página.
Se a app não tem SSO nem checagem assíncrona de sessão, não use — não existiria momento em que ela apareceria.
LoginPage / LoginForm
Tela de login padrão da GPR. Use LoginPage — é uma linha, e o login
fica idêntico em todos os produtos.
import { LoginPage } from 'gpr-ui'
import { useNavigate } from 'react-router-dom'
export function Login() {
const navigate = useNavigate()
const [erro, setErro] = useState<string | null>(null)
return (
<LoginPage
productName="Authenticator"
footer="© 2026 GPR — Uso interno autorizado"
error={erro}
onForgotPassword={() => navigate('/recuperar-senha')}
onSubmit={async ({ email, password }) => {
setErro(null)
try {
await api.login(email, password)
navigate('/painel')
} catch {
setErro('Usuário ou senha inválidos.')
}
}}
/>
)
}Onde fica a fronteira
O kit é dono da apresentação e da mecânica de formulário; o produto é dono da autenticação.
| O kit resolve | O produto resolve |
|---|---|
| Campos, rótulos, espaçamento, tema | Chamada de API |
| Obrigatoriedade e formato de e-mail | Token, sessão, refresh |
| Foco no primeiro campo inválido | Rota pós-login |
| Estado de carregando durante o submit | Texto do erro de credencial |
| aria-invalid / aria-describedby / role="alert" | Regra de bloqueio, MFA, SSO |
| autocomplete pro gerenciador de senhas | |
Autenticação não pode morar num design system: muda por produto e envelheceria a biblioteca junto.
Carregando e erro
Se onSubmit devolver uma Promise, o formulário assume o carregando
sozinho — não precisa controlar loading. Passe loading só quando o
estado vive fora (React Query, Redux).
Se onSubmit rejeitar, o kit para o carregando, chama onError e mostra
uma mensagem genérica. A mensagem crua do erro nunca é exibida: numa
tela de login ela vira pista pra quem está sondando. Quem escreve a cópia
real é o produto, via error.
Login por CPF ou matrícula
<LoginPage
identifierType="text"
emailLabel="CPF"
emailPlaceholder="000.000.000-00"
onSubmit={…}
/>Com identifierType="text" só a obrigatoriedade é checada — o formato
fica por conta do produto.
Só o formulário
Quando a moldura precisa ser outra (registro, primeiro acesso, convite):
<AuthLayout title="Bem-vindo de volta" description="Continue de onde parou">
<LoginForm onSubmit={…}>
<p className="text-center text-sm text-muted-foreground">
Não tem conta? <Link to="/cadastro">Criar agora</Link>
</p>
</LoginForm>
</AuthLayout>| Prop | Tipo | Default | Descrição |
|---|---|---|---|
| onSubmit | (c: LoginCredentials) => void \| Promise<void> | — | Obrigatória. Promise ⇒ carregando automático |
| error | ReactNode | — | Erro de autenticação, em role="alert" |
| onError | (e: unknown) => void | — | Chamado quando onSubmit rejeita (telemetria) |
| loading | boolean | — | Carregando controlado; omita se onSubmit é async |
| onForgotPassword | () => void | — | Sem isso, o link não aparece |
| identifierType | 'email' \| 'text' | 'email' | text desliga a validação de formato |
| defaultEmail | string | '' | Valor inicial do primeiro campo |
| emailLabel | string | 'E-mail' | |
| emailPlaceholder | string | '[email protected]' | |
| passwordLabel | string | 'Senha' | |
| passwordPlaceholder | string | '••••••••' | |
| submitLabel | string | 'Entrar' | |
| forgotPasswordLabel | string | 'Esqueci minha senha' | |
| requiredMessage | string | 'Campo obrigatório.' | |
| invalidEmailMessage | string | 'Informe um e-mail válido.' | |
| genericErrorMessage | string | 'Não foi possível entrar agora. Tente novamente.' | |
| children | ReactNode | — | Conteúdo extra abaixo do botão |
LoginPage aceita tudo isso mais logo, productName, title,
description e footer, repassados pro AuthLayout.
AuthLayout
Shell visual pras páginas de autenticação. Fundo gradiente GPR, watermark do logo atrás e card centralizado. Não tem lógica — é só o chrome.
Pra login, prefira o LoginPage acima. Use o AuthLayout direto quando a
tela não for login: recuperar senha, definir nova senha, aceitar convite,
primeiro acesso.
import { AuthLayout, Button, Input, Label } from 'gpr-ui'
<AuthLayout
title="Recuperar acesso"
description="Enviaremos um link de redefinição pro seu e-mail"
footer="© 2026 GPR"
>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-mail</Label>
<Input id="email" type="email" autoComplete="email" />
</div>
<Button type="submit" size="lg" className="w-full">
Enviar link
</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-700/gpr-400) |
| 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.
LogoDark / LogoWhite / LogoIcon
Logo completo "GPR Educação" (símbolo + wordmark) e o ícone isolado, todos inline (não dependem de arquivo em /public). Os nomes são agnósticos à marca (Dark/White = cor do wordmark, não o fundo) — não mudam se a empresa for renomeada.
O símbolo (mandala) é sempre dourado (token --gold, #a48b58). Só a cor do wordmark muda entre as variantes:
// Wordmark navy (#0b183a) — para fundos claros
<LogoDark /> // 32px de altura
<LogoDark height={48} />
// Wordmark branco (#fff) — para fundos escuros
<LogoWhite />
// Só o símbolo/mandala — monocromático via currentColor (default text-gold)
<LogoIcon /> // dourado da marca
<LogoIcon className="text-white" /> // branco (fundo escuro)
<LogoIcon className="text-primary" /> // qualquer token
<LogoIcon height={24} />| Componente | Cor do wordmark | Indicado para |
|---|---|---|
| LogoDark | navy #0b183a (fixo) | fundos claros |
| LogoWhite | branco #fff (fixo) | fundos escuros |
| LogoIcon | currentColor (default text-gold) | favicon, avatar, espaços reduzidos |
Todos aceitam as props de <svg> (exceto viewBox/xmlns). Altura default 32px; largura proporcional (logos completos ≈ 6.1; LogoIcon é 1:1). O dourado vem do token --gold — exige o preset + gpr-ui/styles.css no consumidor (já no setup padrão). O wordmark navy/branco é fill inline, então renderiza mesmo sem Tailwind.
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-primary-solid, 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}.
primaryvsprimary-solid—--primaryé o azul da marca (#008DFF): use em anel de foco, bordas, ícones e texto sobre fundo claro. Com texto branco em cima ele rende 3.36:1 e reprova o WCAG AA, então toda superfície preenchida (Buttondefault, Badgedefault, avatar) usabg-primary-solid(#0074D9 — 4.72:1). Para texto azul,text-primary-solidno light edark:text-gpr-300no dark.
Tipografia: text-display-lg/md, text-title-xl/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 (ordem estrita de empilhamento, sem empates): z-base (0) • z-raised (10) • z-sticky (30, navbar) • z-overlay (40, backdrop do drawer) • z-sidebar (50) • z-modal-backdrop (60) • z-modal (70) • z-dropdown (80) • z-toast (90) • z-tooltip (100).
z-dropdownfica ACIMA dez-modalde propósito. Popover transitório (Select, MultiSelect, DropdownMenu) quase sempre é disparado de dentro de um modal, e o Radix o portaliza pro<body>— fora da árvore do Dialog. Com a camada de popover abaixo da de modal, o menu abre atrás do painel e não dá pra clicar. Foi o bug da 0.5.0/0.5.1, corrigido na 0.5.2.A ordem da escala não basta sozinha: o Radix envolve o popover num wrapper com
transform, que cria contexto de empilhamento, então oz-dropdowndo*Contentfica preso lá dentro. Quem empilha no nível do<body>é o wrapper, elevado por uma regra global nostyles.css:[data-radix-popper-content-wrapper] { z-index: 80 !important }. O!importanté obrigatório porque o Radix escreve esse z-index como estilo inline.O build tem um guarda (
scripts/check-z-scale.js) que quebra se a ordem for invertida ou se o valor do wrapper divergir da camadadropdown.
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')As cores --primary (#008DFF) e --primary-solid (#0074D9) não mudam
entre temas — só os neutros e superfícies (regra do Brand Book cap. DS-06).
Exceção deliberada: tons de texto que precisam inverter pra manter
contraste (text-gpr-700 dark:text-gpr-300, text-destructive
dark:text-critical-300). Cor de marca é a mesma; a legibilidade é que muda
de fundo.
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(anel de 2px em--ringcom 2px de respiro). Não escrevafocus:outline-nonenos componentes: era isso que apagava o anel em metade do kit. - Contraste é requisito, não preferência — texto abaixo de 18px precisa
de 4.5:1. Na dúvida, suba um degrau da rampa (
gpr-700no light,gpr-300no dark) em vez de usar o tom 500.
Desenvolvimento local
pnpm install
pnpm playground # preview no browser → http://localhost:5199
pnpm build # single build
pnpm dev # watch mode
pnpm typecheck # lib + playgroundPlayground
pnpm playground sobe uma página com todos os componentes montados —
é o jeito de ver o kit antes de publicar uma versão.
- Importa direto de
src/(via aliasgpr-ui), então editar um componente ou um token recarrega na hora, sempnpm build. - Os imports são escritos igual aos de um produto consumidor, então o playground também serve de documentação executável.
- Duas views: o kit dentro do
AppShelle a tela de login (AuthLayout). - Nada dele é publicado — o
filesdopackage.jsonsó mandadisteREADME.md.
Ao adicionar um componente novo ao kit, adicione também ao playground. Um componente que ninguém consegue ver é um componente que ninguém revisa.
Para 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.5.3 — Aditivo, sem quebra.
- Novo
AuthenticatingSplash: tela de "Entrando…" enquanto a sessão é resolvida (SSO, validação de token). Sem props de propósito. Os produtos que já criaram esse componente localmente podem apagar o arquivo e importar do kit. - Fix: spinner congelava com
prefers-reduced-motion. A regra global neutralizava todas as animações, inclusiveanimate-spin— o indicador virava um arco parado, que não parece menos movimento, parece componente quebrado. Agora o giro continua, mais devagar (1,5s). AfetaButton loading, o skeleton doDataTablee a splash.
- Novo
- 0.5.2 — Fix de verdade: Select / MultiSelect / DropdownMenu dentro de Dialog. A 0.5.1 reordenou a escala (dropdown 80 > modal 70) mas o bug continuou: o Radix envolve o popover num wrapper com
transform, que cria contexto de empilhamento, então oz-dropdowndo conteúdo ficava preso lá dentro e o wrapper ficava emauto. Agora ostyles.csseleva o wrapper direto:[data-radix-popper-content-wrapper] { z-index: 80 !important }. O!importanté necessário porque o Radix escreve esse z-index como estilo inline.- Ação nos produtos: se você adicionou o remendo
[data-radix-popper-content-wrapper] { z-index: … !important }no CSS da app, remova agora — o kit passou a fazer isso. Mantido com valor diferente de 80, ele tira o popover da escala (ficaria acima de toast e tooltip). - Exige
import 'gpr-ui/styles.css'no entry (já era obrigatório). - O guarda do build passou a conferir que o valor do wrapper bate com a camada
dropdown.
- Ação nos produtos: se você adicionou o remendo
- 0.5.1 — Fix (incompleto — veja 0.5.2): Select / MultiSelect / DropdownMenu abriam ATRÁS do Dialog. Regressão introduzida na 0.5.0. A camada de popover estava em 60 e a de modal em 80; como o Radix portaliza o popover pro
<body>, o menu ficava embaixo do painel e não dava pra clicar. A escala foi reordenada:modal-backdrop60,modal70,dropdown80. Nada mais mudou.- Ação nos produtos: remova o remendo
[data-radix-popper-content-wrapper] { z-index: … !important }do CSS da app. Ele deixou de ser necessário e, mantido, atrapalha (fixa o popover num nível que ignora a escala). - Se você fixou z-index próprio contando com
z-modal= 80, revise: agora é 70. - O build passou a ter um guarda (
scripts/check-z-scale.js) que falha se a ordem das camadas for invertida de novo.
- Ação nos produtos: remova o remendo
- 0.5.0 — Minor, não patch: não entra sozinho em quem está em
^0.4.x. Suba um produto por vez e confira a tela.- Novo:
LoginPage/LoginForm(tela de login padrão),MaskedInput+MASKS(CPF, CNPJ, telefone, CEP… ou qualquer formato),Logo(troca com o tema sozinha),pnpm playground. - Correções de contraste (WCAG AA): novo
--primary-solidpras superfícies preenchidas — o azul da marca dava 3.36:1 com texto branco. Também--success,--warning-foreground,--muted-foreground,--destructivedo dark, rodapé e nome do produto noAuthLayout, item ativo da sidebar. - Card: padding vertical passou pro
<Card>(py-6). Card semCardFooternão termina mais colado na borda de baixo. ⚠️ Se você contornava isso compb-*noCardContent, remova. cn()agora usatailwind-merge— oclassNameque você passa finalmente sobrescreve o do kit. ⚠️ Overrides antes ignorados passam a valer.- ⚠️ Z-index remapeado (sem empates):
z-dropdown60,z-modal-backdrop70,z-modal80,z-toast90,z-tooltip100. - Sidebar: item sangra a largura toda; ativo virou lavagem da marca +
gpr-700/gpr-300; hover deixou de ser azul cheio no dark. - Logo nova é o default do
AppShelle doAuthLayout. - A11y: skip link, foco visível com respiro, nome acessível no menu colapsado,
Esc+ trava de scroll no drawer mobile, cabeçalho de tabela ordenável sem perder semântica. - ⚠️
size="lg"do Button foi de 40px pra 44px. A Inter não carrega mais os pesos 300/800 (nenhum componente usa — confira se o seu app usafont-light/font-extrabold).
- Novo:
- 0.4.9 — Fix: ícone do
input[type="date"]agora fica no fim do campo (removidodisplay:flexdoInput/PasswordInput) + regras de posição/contraste (light + dark) nostyles.css. Sem necessidade de override no app. - 0.4.8 — Logos da marca "GPR Educação":
LogoDarkeLogoWhite(símbolo + wordmark) eLogoIcon(só o símbolo). Novo token--gold(#a48b58) e corgoldno preset. - 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.
