Tabs
ProntoAlterna entre views relacionadas no mesmo contexto. Suporta variantes line, pill e segmented. Indicator desliza com motion.
Usar quando
Detalhe de processo (Andamentos, Documentos, Financeiro). Painel com diferentes recortes do mesmo dado. Configurações por categoria.
Não usar quando
Conteúdos não-relacionados (use rotas separadas). Mais de 7 abas (use Sidebar/Menu). Lista filtrada (use Filter chip).
Variantes
Microinterações
| Microinteração | Disparada por | Comportamento | Timing |
|---|---|---|---|
| Indicator slide | click em outra aba | Underline/pílula desliza para nova posição | 300ms cubic-bezier(0.32,0.72,0,1) |
| Content fade | mudança de aba | Conteúdo antigo fade-out + novo fade-in | 150ms cross |
| Hover label | mouseenter | Label muda inkMuted → ink | 150ms ease |
| Keyboard nav | ← / → | Move foco para aba adjacente | instant |
| Activated underline | aba ativa | Underline 2px ink permanente | static |
Acessibilidade
Acessibilidade — checklist
Teclado
| Tab | Foco na aba ativa |
| ← / → | Move foco entre abas |
| Enter / Space | Ativa aba em foco |
| Home / End | Foco na primeira/última aba |
ARIA esperado
- role="tablist" no container
- role="tab" em cada aba; aria-selected="true" na ativa
- role="tabpanel" no conteúdo; aria-labelledby="tab-id"
- tabIndex={0} na aba ativa, tabIndex={-1} nas outras (roving tabindex)
Notas
- Roving tabindex — só a aba ativa tem tabIndex 0; setas movem foco.
- Indicator com transition (300ms ease) é critical pra perceber mudança.
- Em mobile com muitas abas: scroll horizontal com snap.
- Conteúdo de cada panel renderiza só quando aba é ativada (lazy).
Código
'use client';
import { useState } from 'react';
import { T, TYPE, SP } from '@/lib/tokens';
interface Tab { id: string; label: string }
export function Tabs({ tabs, value, onChange }: { tabs: Tab[]; value: string; onChange: (id: string) => void }) {
return (
<div role="tablist" style={{ display: 'flex', gap: SP[1], borderBottom: `1px solid ${T.border}` }}>
{tabs.map(tab => (
<button key={tab.id} role="tab"
aria-selected={value === tab.id}
tabIndex={value === tab.id ? 0 : -1}
onClick={() => onChange(tab.id)}
style={{
padding: `${SP[2]}px ${SP[3]}px`,
background: 'transparent', border: 'none',
borderBottom: `2px solid ${value === tab.id ? T.ink : 'transparent'}`,
fontSize: TYPE.base, fontWeight: 500,
color: value === tab.id ? T.ink : T.inkMuted,
cursor: 'pointer', transition: 'all 150ms ease',
marginBottom: -1,
}}>
{tab.label}
</button>
))}
</div>
);
}Regras
Faça
- ✓Indicator com transition (300ms ease).
- ✓Roving tabindex: só ativa tem tabIndex 0.
- ✓Setas ← → movem foco (não Tab).
- ✓Lazy render: tabpanel só monta quando aba ativada.
- ✓Mobile: scroll horizontal com snap-mandatory.
- ✓Contadores em badge ao lado do label (ex: "Andamentos 12").
Não faça
- ✗Não use mais de 7 abas (use Sidebar).
- ✗Não troque conteúdo abruptamente (sempre transition).
- ✗Não use cor decorativa nas abas (ink/inkMuted basta).
- ✗Não esqueça keyboard nav (← →).
- ✗Não force scroll vertical no panel sem altura definida.
Tokens usados
| Token | Valor | Papel |
|---|---|---|
T.ink | #0A0A0A | aba ativa + indicator |
T.inkMuted | #5A5A5E | abas inativas |
T.border | #E3DFD2 | linha base do tablist |
T.surface3 | #F3F0E6 | pílula inactiva (variante pill) |
TYPE.base | 13 | fontSize |
MOTION.base | 250ms | indicator slide |