Tabs

Pronto

Alterna 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

Line (sublinhado — padrão de detalhe)
Conteúdo da aba Andamentos.
Pill (segmented control com pílula deslizante)
Card (separador como aba — configurações)
Conteúdo da aba Andamentos.
Com badges (contadores)
Conteúdo da aba Andamentos.

Microinterações

MicrointeraçãoDisparada porComportamentoTiming
Indicator slideclick em outra abaUnderline/pílula desliza para nova posição300ms cubic-bezier(0.32,0.72,0,1)
Content fademudança de abaConteúdo antigo fade-out + novo fade-in150ms cross
Hover labelmouseenterLabel muda inkMuted → ink150ms ease
Keyboard nav← / →Move foco para aba adjacenteinstant
Activated underlineaba ativaUnderline 2px ink permanentestatic

Acessibilidade

Acessibilidade — checklist

Teclado
TabFoco na aba ativa
← / →Move foco entre abas
Enter / SpaceAtiva aba em foco
Home / EndFoco 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

TokenValorPapel
T.ink#0A0A0Aaba ativa + indicator
T.inkMuted#5A5A5Eabas inativas
T.border#E3DFD2linha base do tablist
T.surface3#F3F0E6pílula inactiva (variante pill)
TYPE.base13fontSize
MOTION.base250msindicator slide