Table
Tabela de dados semântica — <table> HTML real, alta densidade, cantos retos, números em mono tabular. O header carrega uma régua 2px preta; zebra é opt-in; seleção usa checkbox lime-on-black.
Tabela CRM
Demo completo com todas as colunas: avatar+nome, badge de plano, MRR numérico, barra de utilização, status e ações.
<script setup>
const columns = [
{ id: 'empresa', header: 'Empresa', type: 'avatar' },
{ id: 'plano', header: 'Plano', type: 'badge' },
{ id: 'mrr', header: 'MRR', type: 'numeric' },
{ id: 'uso', header: 'Utilização' },
{ id: 'status', header: 'Status', type: 'badge' },
{ id: 'renovacao', header: 'Renov.', type: 'mono' },
{ id: 'acoes', header: '', type: 'actions' },
]
</script>
<template>
<Table :columns="columns" :data="data" aria-label="Clientes CRM">
<!-- Avatar + nome + sub-label -->
<template #cell-empresa="{ row }">
<div style="display:flex;align-items:center;gap:10px;">
<Avatar :name="row.empresa" size="sm" />
<div>
<div style="font-weight:600;">{{ row.empresa }}</div>
<div style="font-size:11px;color:#878489;">{{ row.email }}</div>
</div>
</div>
</template>
<!-- Badge soft para plano e status (nunca solid em tabelas densas) -->
<template #cell-plano="{ row }">
<Badge family="soft" :tone="planTone[row.plano]">{{ row.plano }}</Badge>
</template>
<!-- MRR formatado -->
<template #cell-mrr="{ row }">
{{ 'R$ ' + row.mrr.toLocaleString('pt-BR') }}
</template>
<!-- Barra de utilização inline 80×6px -->
<template #cell-uso="{ row }">
<div style="display:inline-flex;align-items:center;gap:8px;">
<div style="width:80px;height:6px;background:#e3dde3;position:relative;">
<div :style="{ width: row.uso + '%', height: '100%', background: '#3381ff', position:'absolute' }" />
</div>
<span style="font-family:monospace;font-size:12px;color:#878489;">{{ row.uso }}%</span>
</div>
</template>
<!-- Botão de ações — 44px hit target, aria-label obrigatório -->
<template #cell-acoes="{ row }">
<button style="width:44px;height:44px;..." :aria-label="`Ações para ${row.empresa}`">
<svg><!-- kebab ⋮ --></svg>
</button>
</template>
</Table>
</template>Seleção
Checkboxes quadrados com check lime. Master checkbox assume estado indeterminate automaticamente quando a seleção é parcial. A barra de ações em lote aparece no topo ao selecionar ≥1 linha.
Master checkbox: false → indeterminate → true conforme a contagem de linhas selecionadas.
Linha selecionada: #ecf3ff + barra azul 3px à esquerda — duas pistas visuais redundantes (cor + posição).
<script setup>
const selectedIds = ref([])
</script>
<template>
<Table
:columns="columns"
:data="data"
:selectable="true"
:selected-ids="selectedIds"
aria-label="Equipe"
@update:selected-ids="selectedIds = $event"
>
<!-- Ações em lote — aparece ao selecionar ≥1 linha -->
<template #bulk-actions="{ count }">
<Button size="xs" variant="ghost">Exportar {{ count }}</Button>
<Button size="xs" variant="ghost">Desativar</Button>
</template>
</Table>
</template>Sort
Ciclo none → asc → desc → none por coluna. Sort emite update:sortBy — a ordenação real fica no consumer (local ou server-side). Reposicionamento das linhas é instantâneo, sem animação.
<script setup>
const sortBy = ref(null)
// Sort local — para server-side, dispare a request no handler
const sortedData = computed(() => {
if (!sortBy.value) return data
const { id, dir } = sortBy.value
return [...data].sort((a, b) => {
const cmp = typeof a[id] === 'number'
? a[id] - b[id]
: String(a[id]).localeCompare(String(b[id]))
return dir === 'asc' ? cmp : -cmp
})
})
</script>
<template>
<Table
:columns="columns"
:data="sortedData"
:sort-by="sortBy"
aria-label="Pipeline"
@update:sort-by="sortBy = $event"
/>
</template>O header da coluna numérica se alinha à direita automaticamente quando type: 'numeric'.
Densidades
Três densidades. Nunca misture em tabelas adjacentes da mesma tela.
<Table density="sm" ... /> <!-- 8px v-padding — logs, auditoria -->
<Table density="md" ... /> <!-- 14px — padrão CRM, pipeline -->
<Table density="lg" ... /> <!-- 20px — comparação de planos, ≤10 -->| Density | Row height | Uso |
|---|---|---|
sm | ~32px | Logs, eventos, auditoria. Mostra 20+ linhas simultaneamente |
md | ~48px | Padrão. CRM, listagem de clientes, pipeline, faturas |
lg | ~64px | Tabelas narrativas, ≤10 linhas. Cada linha vira um item |
Zebra
Opt-in para tabelas financeiras ou de log com muitas colunas numéricas. Não combine com hover colorido — competem visualmente.
<!-- Ideal para logs e exports financeiros com density="sm" -->
<Table :zebra="true" density="sm" :columns="columns" :data="data" />Estados
Carregando — sem dados (4 pontos)
Carregando — com dados (skeleton rows)
Thead permanece visível. Linhas skeleton com shimmer animado. Use skeleton-rows para ajustar a quantidade.
<!-- Sem dados: 4 pontos pulsantes -->
<Table :data="[]" :loading="true" :columns="columns" aria-label="..." />
<!-- Com dados: skeleton rows (thead visível, dados antigos preservados) -->
<Table :data="lastData" :loading="true" :skeleton-rows="4" :columns="columns" aria-label="..." />Vazio — sem dados ainda
Vazio — filtros sem resultado
Mensagem diferente quando a lista existe mas o filtro não casou. Sempre ofereça uma ação de escape.
<!-- Empty inicial -->
<Table :data="[]" empty-title="Nenhum cliente ainda"
empty-description="Adicione o primeiro para começar." aria-label="Clientes">
<template #empty-cta>
<Button size="sm" variant="primary">Novo cliente</Button>
</template>
</Table>
<!-- Empty filtrado — mensagem de escape sempre presente -->
<Table :data="[]" :is-filtered="true"
empty-filtered-title="Nenhum resultado"
empty-filtered-description="Tente remover os filtros.">
<template #empty-cta>
<Button size="sm" variant="outline">Limpar filtros</Button>
</template>
</Table>Erro
O estado de erro nunca sobrescreve dados existentes — se havia linhas, elas ficam com um banner no topo. Aqui o caso onde o carregamento inicial falhou.
<Table
:columns="columns"
:data="[]"
:error="{ code: 'ERR_TIMEOUT', message: 'O servidor demorou para responder.' }"
aria-label="Relatório"
@retry="fetchData"
/>Com paginação
Rodapé com contagem Mostrando X–Y de N, seletor de linhas por página e paginação numérica. Integra com o componente Pagination interno.
<Table
:columns="columns"
:data="pageData"
:show-pagination="true"
:total-rows="248"
:current-page="page"
:rows-per-page="perPage"
:rows-per-page-options="[8, 16, 32]"
aria-label="Clientes"
@update:current-page="page = $event; fetchPage($event)"
@update:rows-per-page="perPage = $event; fetchPage(1)"
/>Não use infinite scroll. Quebra
Ctrl+F, deep-link e o sticky footer. Use paginação ou "Carregar mais" explícito.
Sticky header
O header gruda no topo ao rolar quando max-height é definido. O background do thead precisa ser opaco — sem isso, as linhas do corpo "vazam" por trás ao rolar.
<Table
:columns="columns"
:data="data"
:sticky-header="true"
max-height="480px"
aria-label="Log de eventos (300 linhas)"
/>Tipos de célula
O type da coluna afeta alinhamento e fonte — decidido pelo dado, não pelo uso.
type | Alinhamento | Fonte | Quando usar |
|---|---|---|---|
text | esquerda | body | Nomes, descrições, categorias |
numeric | direita | mono tabular | Moeda, %, contagem — header tb alinha |
mono | esquerda | mono | IDs, slugs, timestamps, regiões |
badge | esquerda | herda badge | Status, plano — sempre Badge soft |
avatar | esquerda | mix | Identificador principal com avatar |
actions | direita | — | Kebab, sempre última coluna, 44px |
Toolbar (slot)
Use o slot toolbar para título, busca, filtros e CTA. Fica fora do <table> para não interferir na semântica.
<Table :columns="columns" :data="data" aria-label="Clientes">
<template #toolbar>
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid #d6cfd6;">
<div>
<span style="font-weight:700;">Clientes</span>
<span style="color:#878489;margin-left:8px;font-size:13px;">248 registros</span>
</div>
<div style="display:flex;gap:8px;">
<Input placeholder="Buscar..." size="sm" />
<Button size="sm" variant="primary">+ Novo</Button>
</div>
</div>
</template>
</Table>Props
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
columns | TableColumn[] | obrigatório | Definição das colunas |
data | Record<string,unknown>[] | [] | Linhas de dados |
aria-label | string | obrigatório | Descreve o conteúdo — obrigatório para a11y |
density | 'sm' | 'md' | 'lg' | 'md' | Densidade visual |
zebra | boolean | false | Listras alternadas (opt-in) |
bordered | boolean | true | Borda externa no wrapper |
selectable | boolean | false | Habilita checkboxes e barra de ações em lote |
selected-ids | string[] | [] | IDs selecionados — use com @update:selected-ids |
sort-by | TableSortState | null | null | Sort ativo — use com @update:sort-by |
loading | boolean | false | Exibe dots (sem data) ou skeleton (com data) |
error | TableError | null | null | Estado de erro com botão de retry |
sticky-header | boolean | false | Header sticky (precisa de max-height) |
max-height | string | — | Altura máxima do scroll container |
total-rows | number | — | Total real para aria-rowcount e paginação |
caption | string | — | Caption visualmente oculto — complementa a11y |
show-pagination | boolean | false | Exibe rodapé com paginação |
current-page | number | 1 | Página atual |
rows-per-page | number | 8 | Linhas por página |
rows-per-page-options | number[] | [8,16,32] | Opções do seletor de linhas por página |
get-row-id | (row) => string | row.id | Extrai o ID único da linha |
is-filtered | boolean | false | Alterna o empty state para mensagem de filtro |
skeleton-rows | number | 4 | Quantidade de linhas skeleton no loading |
mobile-cards | boolean | false | Converte para lista de cards em < 720px |
empty-title | string | — | Título do empty state inicial |
empty-description | string | — | Descrição do empty state inicial |
empty-filtered-title | string | — | Título quando filtros não retornam resultado |
empty-filtered-description | string | — | Descrição do empty filtrado |
TableColumn
| Campo | Tipo | Descrição |
|---|---|---|
id | string | Chave no objeto de dados + nome do slot |
header | string | Texto do cabeçalho (string vazia OK p/ ações) |
type | 'text' | 'numeric' | 'mono' | 'badge' | 'avatar' | 'actions' | Tipo da célula — afeta alinhamento e fonte |
sortable | boolean | Habilita o ciclo de sort nesta coluna |
width | string | number | Largura fixa ('120px', '20%', 120) |
minWidth | number | Largura mínima em px |
sticky | 'left' | 'right' | Congela a coluna em scroll horizontal |
ariaLabel | string | Sobrescreve o header para leitores de tela |
Slots
| Slot | Props | Descrição |
|---|---|---|
cell-{id} | { row, value, index } | Conteúdo customizado de célula por coluna |
head-{id} | — | Cabeçalho customizado por coluna |
toolbar | — | Área acima da tabela: título, busca, filtros, CTA |
bulk-actions | { count, ids } | Botões dentro da barra de seleção em lote |
empty | — | Substitui completamente o empty state |
empty-cta | — | CTA dentro do empty state padrão |
Eventos
| Evento | Payload | Descrição |
|---|---|---|
update:selectedIds | string[] | Seleção mudou |
update:sortBy | TableSortState | null | Sort mudou (coluna + direção) |
update:currentPage | number | Página mudou |
update:rowsPerPage | number | Linhas por página mudou |
retry | — | "Tentar novamente" clicado |
row-click | row | Linha clicada (fora de checkbox/ações) |
Acessibilidade
<table>semântico — nunca<div>fake-table. Leitor de tela depende de<thead>/<tbody>/<th>/<td>.aria-labelobrigatório na prop — descreve o conteúdo da tabela.<th scope="col">em todos os cabeçalhos.aria-sortatualizado no<th>de cada coluna ordenável.- Master checkbox com
aria-label="Selecionar todas as linhas"eindeterminatereal via JS. aria-rowcountearia-rowindexsetados automaticamente em tabelas paginadas server-side.aria-labelpor linha em cada checkbox:"Selecionar linha de {id}".prefers-reduced-motiondesliga todas as transições e animações de skeleton.- Contraste da linha selecionada (
#ecf3ff+ texto preto) passa 4.5:1. A barra azul é redundância visual, não substituto de cor.