Empty State
ProntoMensagem amigável quando uma listagem ou área está vazia. Sempre tem ícone, título, descrição e CTA quando aplicável.
Usar quando
Lista vazia em primeiro acesso (zero state). Resultado de busca/filtro vazio. Acesso negado. Área aguardando dado externo.
Não usar quando
Loading (use Skeleton). Erro de rede (use ErrorState). Lista temporariamente vazia mas com dados em outras abas (mostre número 0 sem empty state cheio).
Variantes
4 contextos cobrem 95% dos casos.
Anatomia
5 partes; 3 são obrigatórias.
Microinterações
| Microinteração | Disparada por | Comportamento | Timing |
|---|---|---|---|
| Mount fade-in | componente entra no DOM | opacity 0→1 + translateY 8px→0 do conjunto | 250ms cubic-bezier(0.32,0.72,0,1) |
| Ícone pulse (sutil) | mount, depois 1x a cada 8s | scale 1→1.05→1 do círculo do ícone | 600ms ease in-out |
| CTA hover | mouseenter no botão primário | Background ink → ink-2 (padrão Button) | 150ms ease |
Acessibilidade
Acessibilidade — checklist
ARIA esperado
- role="status" // se transitório (search vazio depois de filtrar)
- aria-live="polite" // anuncia "nenhum resultado" para screen reader
Notas
- Empty state NÃO é um erro — não use cor carmim ou ícone de aviso.
- Ícone deve ser semanticamente significativo (Inbox para "sem mensagens", FileSearch para "busca vazia").
- CTA é primary mas NÃO bloqueia (usuário pode ignorar e navegar).
- Texto descreve a ação ("Crie sua primeira área"), não o problema ("Lista vazia").
Código
'use client';
import { Plus, Scale } from 'lucide-react';
import { T, FONT, TYPE, WEIGHT, SP, RADIUS, transition } from '@/lib/tokens';
interface EmptyStateProps {
icon: React.ComponentType<{ size?: number }>;
title: string;
description: string;
cta?: { label: string; onClick: () => void; icon?: React.ComponentType };
link?: { label: string; href: string };
}
export function EmptyState({ icon: Icon, title, description, cta, link }: EmptyStateProps) {
return (
<div
role="status"
aria-live="polite"
style={{
background: T.surface,
border: `1px dashed ${T.border}`,
borderRadius: RADIUS.lg,
padding: `${SP[16]}px ${SP[6]}px`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: SP[3],
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 980,
background: T.brassTint,
color: T.brass,
display: 'grid',
placeItems: 'center',
}}
>
<Icon size={20} />
</div>
<h3 style={{
fontFamily: FONT.display,
fontSize: TYPE.xl,
fontWeight: WEIGHT.semibold,
color: T.ink,
margin: 0,
lineHeight: 1.2,
}}>
{title}
</h3>
<p style={{
fontSize: TYPE.base,
color: T.inkMuted,
margin: 0,
maxWidth: 360,
lineHeight: 1.5,
}}>
{description}
</p>
{cta && (
<button onClick={cta.onClick} /* ... estilo do Button primary */>
{cta.icon && <cta.icon size={14} />}
{cta.label}
</button>
)}
{link && (
<a href={link.href} style={{ fontSize: TYPE.sm, color: T.inkMuted }}>
{link.label}
</a>
)}
</div>
);
}Regras
Faça
- ✓Use ícone semanticamente significativo (Inbox para mensagens, FileSearch para busca, Lock para permissão).
- ✓Título no imperativo: "Crie sua primeira área", não "Lista vazia".
- ✓Descrição em 1-2 frases. Diga o que fazer, não o problema.
- ✓CTA sempre que houver ação imediata possível.
- ✓Padding generoso (SP[16] vertical) — empty state precisa respirar.
- ✓Animação sutil de mount (fade + slide).
Não faça
- ✗Não use cor carmim ou ícone de erro (não é erro).
- ✗Não use ilustrações grandes (custo perceptual alto).
- ✗Não use 2 CTAs primários competindo.
- ✗Não chame de "vazio" — chame de "primeiro acesso", "sem resultados", etc.
- ✗Não esconda navegação ou outras ações da página inteira.
- ✗Não force descrição longa — 2 frases máx.
Tokens usados
| Token | Valor | Papel |
|---|---|---|
T.surface | #FFFFFF | background do container |
T.border | #E3DFD2 | borda dashed |
T.brassTint | rgba(164,124,43,0.08) | background do círculo do ícone |
T.brass | #A47C2B | cor do ícone |
TYPE.xl | 21 | fontSize do título |
TYPE.base | 13 | fontSize da descrição |
SP[16] | 64 | padding vertical do container |
RADIUS.lg | 18 | borderRadius |