Skip to content

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.

vue
<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: falseindeterminatetrue conforme a contagem de linhas selecionadas.

Linha selecionada: #ecf3ff + barra azul 3px à esquerda — duas pistas visuais redundantes (cor + posição).

vue
<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.

vue
<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.

vue
<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  -->
DensityRow heightUso
sm~32pxLogs, eventos, auditoria. Mostra 20+ linhas simultaneamente
md~48pxPadrão. CRM, listagem de clientes, pipeline, faturas
lg~64pxTabelas 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.

vue
<!-- 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.

vue
<!-- 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.

vue
<!-- 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.

vue
<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.

vue
<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.


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.

vue
<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.

typeAlinhamentoFonteQuando usar
textesquerdabodyNomes, descrições, categorias
numericdireitamono tabularMoeda, %, contagem — header tb alinha
monoesquerdamonoIDs, slugs, timestamps, regiões
badgeesquerdaherda badgeStatus, plano — sempre Badge soft
avataresquerdamixIdentificador principal com avatar
actionsdireitaKebab, 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.

vue
<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

PropTipoPadrãoDescrição
columnsTableColumn[]obrigatórioDefinição das colunas
dataRecord<string,unknown>[][]Linhas de dados
aria-labelstringobrigatórioDescreve o conteúdo — obrigatório para a11y
density'sm' | 'md' | 'lg''md'Densidade visual
zebrabooleanfalseListras alternadas (opt-in)
borderedbooleantrueBorda externa no wrapper
selectablebooleanfalseHabilita checkboxes e barra de ações em lote
selected-idsstring[][]IDs selecionados — use com @update:selected-ids
sort-byTableSortState | nullnullSort ativo — use com @update:sort-by
loadingbooleanfalseExibe dots (sem data) ou skeleton (com data)
errorTableError | nullnullEstado de erro com botão de retry
sticky-headerbooleanfalseHeader sticky (precisa de max-height)
max-heightstringAltura máxima do scroll container
total-rowsnumberTotal real para aria-rowcount e paginação
captionstringCaption visualmente oculto — complementa a11y
show-paginationbooleanfalseExibe rodapé com paginação
current-pagenumber1Página atual
rows-per-pagenumber8Linhas por página
rows-per-page-optionsnumber[][8,16,32]Opções do seletor de linhas por página
get-row-id(row) => stringrow.idExtrai o ID único da linha
is-filteredbooleanfalseAlterna o empty state para mensagem de filtro
skeleton-rowsnumber4Quantidade de linhas skeleton no loading
mobile-cardsbooleanfalseConverte para lista de cards em < 720px
empty-titlestringTítulo do empty state inicial
empty-descriptionstringDescrição do empty state inicial
empty-filtered-titlestringTítulo quando filtros não retornam resultado
empty-filtered-descriptionstringDescrição do empty filtrado

TableColumn

CampoTipoDescrição
idstringChave no objeto de dados + nome do slot
headerstringTexto do cabeçalho (string vazia OK p/ ações)
type'text' | 'numeric' | 'mono' | 'badge' | 'avatar' | 'actions'Tipo da célula — afeta alinhamento e fonte
sortablebooleanHabilita o ciclo de sort nesta coluna
widthstring | numberLargura fixa ('120px', '20%', 120)
minWidthnumberLargura mínima em px
sticky'left' | 'right'Congela a coluna em scroll horizontal
ariaLabelstringSobrescreve o header para leitores de tela

Slots

SlotPropsDescriçã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
emptySubstitui completamente o empty state
empty-ctaCTA dentro do empty state padrão

Eventos

EventoPayloadDescrição
update:selectedIdsstring[]Seleção mudou
update:sortByTableSortState | nullSort mudou (coluna + direção)
update:currentPagenumberPágina mudou
update:rowsPerPagenumberLinhas por página mudou
retry"Tentar novamente" clicado
row-clickrowLinha clicada (fora de checkbox/ações)

Acessibilidade

  • <table> semântico — nunca <div> fake-table. Leitor de tela depende de <thead>/<tbody>/<th>/<td>.
  • aria-label obrigatório na prop — descreve o conteúdo da tabela.
  • <th scope="col"> em todos os cabeçalhos.
  • aria-sort atualizado no <th> de cada coluna ordenável.
  • Master checkbox com aria-label="Selecionar todas as linhas" e indeterminate real via JS.
  • aria-rowcount e aria-rowindex setados automaticamente em tabelas paginadas server-side.
  • aria-label por linha em cada checkbox: "Selecionar linha de {id}".
  • prefers-reduced-motion desliga 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.