Estado Vazio
O que mostrar quando não há o que mostrar: lista vazia, busca sem resultado ou falha ao carregar, com ícone, título, descrição e uma ação para o próximo passo. No tamanho grande vira tela inteira de erro ou 404, com código para o suporte.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<EstadoVazio>
O que mostrar quando não há o que mostrar: lista vazia, busca sem resultado ou falha ao carregar. Orienta o próximo passo com uma ação (criar o primeiro item, limpar os filtros, tentar de novo). Em `lg`, vira a tela inteira de um erro ou de uma área vazia, com o ícone numa bolha tingida e título.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| tituloobrigatória | ReactNode | — | O que não há (ou o que deu errado). Frase curta. |
| descricao | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Como sair daqui. Uma ou duas frases. |
| variante | "erro" | "vazio" | "busca" | "vazio" | `vazio` (nada criado ainda), `busca` (os filtros não acharam nada) ou `erro` (falhou ao carregar). Muda o ícone, a cor e o anúncio. |
| tamanho | "sm" | "md" | "lg" | "md" | `sm` dentro de um campo ou lista curta, `md` no corpo de um cartão ou modal, `lg` ocupando a tela. |
| icone | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Substitui o ícone padrão da variante (SVG; sem classe de tamanho, acompanha o tamanho). |
| acao | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | O que resolve o vazio: normalmente um ou dois botões. |
| codigo | string | — | Código para o suporte achar o erro nos registros (o digest do Next, um id de requisição). Selecionável com um clique. |
| rotuloDoCodigo | string | "Código para o suporte:" | |
| nivel | 2 | 3 | 1 | 2 | Nível do título em `lg` (nos outros tamanhos o título é um parágrafo). |
| focarTitulo | boolean | false | Leva o foco ao título ao aparecer (em `lg`), para quem usa teclado ou leitor de tela saber que a tela trocou de conteúdo. Útil em telas de erro que substituem a página. |
| className | string | "" |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useRef, type ReactNode } from "react";
function Desenho({ children }: { children: ReactNode }) {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
}
const VARIANTES = {
vazio: {
cor: "text-muted",
bolha: "border-accent/25 bg-accent/10 text-accent",
icone: (
<Desenho>
<path d="M3 13h5l1.5 3h5L16 13h5" />
<path d="M5.5 5h13L21 13v6H3v-6l2.5-8z" />
</Desenho>
),
},
busca: {
cor: "text-muted",
bolha: "border-rule bg-surface text-muted",
icone: (
<Desenho>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-4-4M8.5 8.5l5 5m0-5-5 5" />
</Desenho>
),
},
erro: {
cor: "text-critical",
bolha: "border-critical/30 bg-critical/10 text-critical",
icone: (
<Desenho>
<path d="M12 3 2.5 20h19L12 3z" />
<path d="M12 10v4M12 17.5h.01" />
</Desenho>
),
},
} as const;
const TAMANHOS = {
sm: { caixa: "gap-1.5 px-3 py-4", icone: "[&_svg:not([class*='size-'])]:size-5" },
md: { caixa: "gap-2 p-10", icone: "[&_svg:not([class*='size-'])]:size-6" },
lg: { caixa: "gap-5 px-6 py-16", icone: "[&_svg:not([class*='size-'])]:size-7" },
} as const;
export type VarianteDeEstadoVazio = keyof typeof VARIANTES;
/**
* O que mostrar quando não há o que mostrar: lista vazia, busca sem resultado
* ou falha ao carregar. Orienta o próximo passo com uma ação (criar o primeiro
* item, limpar os filtros, tentar de novo). Em `lg`, vira a tela inteira de um
* erro ou de uma área vazia, com o ícone numa bolha tingida e título.
*/
export function EstadoVazio({
titulo,
descricao,
variante = "vazio",
tamanho = "md",
icone,
acao,
codigo,
rotuloDoCodigo = "Código para o suporte:",
nivel = 2,
focarTitulo = false,
className = "",
}: {
/** O que não há (ou o que deu errado). Frase curta. */
titulo: ReactNode;
/** Como sair daqui. Uma ou duas frases. */
descricao?: ReactNode;
/** `vazio` (nada criado ainda), `busca` (os filtros não acharam nada) ou `erro` (falhou ao carregar). Muda o ícone, a cor e o anúncio. */
variante?: VarianteDeEstadoVazio;
/** `sm` dentro de um campo ou lista curta, `md` no corpo de um cartão ou modal, `lg` ocupando a tela. */
tamanho?: keyof typeof TAMANHOS;
/** Substitui o ícone padrão da variante (SVG; sem classe de tamanho, acompanha o tamanho). */
icone?: ReactNode;
/** O que resolve o vazio: normalmente um ou dois botões. */
acao?: ReactNode;
/** Código para o suporte achar o erro nos registros (o digest do Next, um id de requisição). Selecionável com um clique. */
codigo?: string;
rotuloDoCodigo?: string;
/** Nível do título em `lg` (nos outros tamanhos o título é um parágrafo). */
nivel?: 1 | 2 | 3;
/**
* Leva o foco ao título ao aparecer (em `lg`), para quem usa teclado ou leitor de
* tela saber que a tela trocou de conteúdo. Útil em telas de erro que substituem a página.
*/
focarTitulo?: boolean;
className?: string;
}) {
const v = VARIANTES[variante];
const t = TAMANHOS[tamanho];
const grande = tamanho === "lg";
const refTitulo = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (focarTitulo && grande) refTitulo.current?.focus();
}, [focarTitulo, grande]);
const H = `h${nivel}` as const;
// Erro é anunciado na hora; busca sem resultado, com educação (é a resposta a uma ação).
const papel = variante === "erro" ? "alert" : variante === "busca" ? "status" : undefined;
return (
<div
role={papel}
className={`flex flex-col items-center justify-center text-center ${t.caixa} ${
grande ? "mx-auto max-w-lg" : "rounded-lg border border-dashed border-rule bg-surface"
} ${className}`}
>
{grande ? (
<span className={`flex size-14 items-center justify-center rounded-full border ${v.bolha} ${t.icone}`}>{icone ?? v.icone}</span>
) : (
<span className={`${v.cor} ${t.icone}`}>{icone ?? v.icone}</span>
)}
{grande ? (
<div className="flex flex-col gap-2">
<H ref={refTitulo} tabIndex={focarTitulo ? -1 : undefined} className="font-display text-2xl font-bold text-ink outline-none">
{titulo}
</H>
{descricao != null && <p className="leading-relaxed text-ink-2">{descricao}</p>}
</div>
) : (
<>
<p className={`font-medium text-ink-2 ${tamanho === "sm" ? "text-sm" : ""}`}>{titulo}</p>
{descricao != null && <p className="max-w-md text-sm text-muted">{descricao}</p>}
</>
)}
{acao != null && <div className={`flex flex-wrap items-center justify-center gap-2 ${grande ? "" : "mt-2"}`}>{acao}</div>}
{codigo && (
<p className="text-xs text-muted">
{rotuloDoCodigo} <span className="font-mono select-all">{codigo}</span>
</p>
)}
</div>
);
}
import type { ReactNode } from "react";
import { defineStories } from "@/registry/stories";
import { EstadoVazio, type VarianteDeEstadoVazio } from "./EstadoVazio";
function Mais() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
);
}
function Recarregar() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
</svg>
);
}
function Bussola() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="9" />
<path d="m15.5 8.5-2 5-5 2 2-5 5-2z" />
</svg>
);
}
const TEXTOS: Record<VarianteDeEstadoVazio, { titulo: string; descricao: string; acao: string }> = {
vazio: {
titulo: "Nenhum projeto ainda",
descricao: "Crie o primeiro projeto para começar a organizar as tarefas da equipe.",
acao: "Criar projeto",
},
busca: {
titulo: "Nenhum resultado para “relatório anual”",
descricao: "Confira a grafia ou tire alguns filtros.",
acao: "Limpar filtros",
},
erro: {
titulo: "Não foi possível carregar os pedidos",
descricao: "Algo falhou do nosso lado. Tente de novo em instantes.",
acao: "Tentar novamente",
},
};
/** Botão simples só para estes exemplos (a biblioteca não depende de outro componente). */
function Botao({
variante = "secundaria",
tamanho = "md",
icone,
children,
}: {
variante?: "primaria" | "secundaria";
tamanho?: "sm" | "md";
icone?: ReactNode;
children: ReactNode;
}) {
const cores = variante === "primaria" ? "bg-ink text-page" : "border border-rule text-ink hover:bg-surface";
const medidas = tamanho === "sm" ? "px-3 py-1.5 text-xs" : "px-4 py-2 text-sm";
return (
<button type="button" className={`inline-flex items-center gap-2 rounded-lg font-medium transition-colors ${cores} ${medidas}`}>
{icone}
{children}
</button>
);
}
export default defineStories({
args: {
variante: "vazio",
tamanho: "md",
titulo: TEXTOS.vazio.titulo,
descricao: TEXTOS.vazio.descricao,
comAcao: true,
codigo: "",
},
controls: {
variante: { type: "select", options: ["vazio", "busca", "erro"] },
tamanho: { type: "select", options: ["sm", "md", "lg"] },
titulo: { type: "text" },
descricao: { type: "text" },
comAcao: { type: "boolean", label: "acao" },
codigo: { type: "text", label: "codigo (vazio = sem)" },
},
render: (a) => {
const variante = a.variante as VarianteDeEstadoVazio;
return (
<div className="w-full max-w-xl">
<EstadoVazio
variante={variante}
tamanho={a.tamanho as "sm" | "md" | "lg"}
titulo={a.titulo}
descricao={a.descricao || undefined}
codigo={a.codigo || undefined}
acao={
a.comAcao ? (
<Botao
variante={variante === "vazio" ? "primaria" : "secundaria"}
tamanho={a.tamanho === "sm" ? "sm" : "md"}
icone={variante === "vazio" ? <Mais /> : variante === "erro" ? <Recarregar /> : undefined}
>
{TEXTOS[variante].acao}
</Botao>
) : undefined
}
/>
</div>
);
},
stories: [
{ name: "Padrão" },
{
name: "Vazio, busca e erro",
render: () => (
<div className="grid w-full max-w-4xl gap-4 md:grid-cols-3">
<EstadoVazio
titulo="Nenhum projeto ainda"
descricao="Crie o primeiro para começar."
acao={
<Botao variante="primaria" tamanho="sm" icone={<Mais />}>
Criar projeto
</Botao>
}
/>
<EstadoVazio
variante="busca"
titulo="Nenhum resultado"
descricao="Tire alguns filtros."
acao={<Botao tamanho="sm">Limpar filtros</Botao>}
/>
<EstadoVazio
variante="erro"
titulo="Falha ao carregar"
descricao="Tente de novo em instantes."
acao={
<Botao tamanho="sm" icone={<Recarregar />}>
Tentar novamente
</Botao>
}
/>
</div>
),
},
{
name: "Tela de erro (lg)",
render: () => (
<EstadoVazio
variante="erro"
tamanho="lg"
nivel={1}
titulo="Esta tela não carregou"
descricao="Algo quebrou ao montar a página e o erro já foi registrado. Você pode tentar de novo; o resto do sistema segue funcionando."
codigo="a3f9c21e"
acao={
<>
<Botao variante="primaria" icone={<Recarregar />}>
Tentar novamente
</Botao>
<Botao>Ir para o início</Botao>
</>
}
/>
),
},
{
name: "Página não encontrada (lg)",
render: () => (
<EstadoVazio
variante="busca"
tamanho="lg"
nivel={1}
icone={<Bussola />}
titulo="Esta página não existe"
descricao="O endereço pode ter mudado, ou você não tem acesso a esta área."
acao={<Botao variante="primaria">Voltar ao início</Botao>}
/>
),
},
{
name: "Pequeno, dentro de uma lista",
render: () => (
<div className="w-full max-w-xs">
<EstadoVazio tamanho="sm" titulo="Nenhum comentário" descricao="Seja o primeiro a comentar." />
</div>
),
},
],
});
Para usar em outro projeto, copie também
Os componentes de que este depende e o tema. O CSS do tema é um arquivo só: importe depois do Tailwind.
Tema escolhido no cabeçalho (com a ponte para o Tailwind)
Ver o CSS do tema
/* Tema "Editorial", gerado no Component Vault.
Importe depois de @import "tailwindcss". Carregue as fontes "Archivo" e "Inter" no projeto. */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-track: var(--track);
--color-grid: var(--grid);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}
:root {
color-scheme: light dark;
--page: light-dark(#ffffff, #0f0f0e);
--surface: light-dark(#f5f5f5, #1a1a19);
--track: light-dark(#f0f0f0, #262624);
--grid: light-dark(#ededed, #232321);
--ink: light-dark(#111111, #f0efec);
--ink-2: light-dark(#4a4a4a, #c3c2b7);
--muted: light-dark(#666666, #a3a29a);
--rule: light-dark(#e3e3e3, #2c2c2a);
--rule-strong: light-dark(#111111, #f0efec);
--axis: light-dark(#9a9a9a, #6b6a64);
--accent: light-dark(#1a56db, #7aa2f7);
--success: light-dark(#13865c, #3fc48f);
--warning: #fab219;
--critical: #d03b3b;
--series-1: light-dark(#2a78d6, #3987e5);
--series-2: light-dark(#eb6834, #d95926);
--series-3: light-dark(#1baf7a, #199e70);
--series-4: light-dark(#eda100, #c98500);
--series-5: light-dark(#e87ba4, #d55181);
--series-6: #008300;
--series-7: light-dark(#6250d6, #9085e9);
--series-8: light-dark(#e34948, #e66767);
--fonte-titulo: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-texto: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--spacing: 0.25rem;
--text-xs: 0.8rem;
--text-sm: 0.8944rem;
--text-base: 1rem;
--text-lg: 1.118rem;
--text-xl: 1.25rem;
--text-2xl: 1.5625rem;
--text-3xl: 1.9531rem;
--text-4xl: 2.4414rem;
--text-5xl: 3.0518rem;
--text-6xl: 3.8147rem;
--text-7xl: 4.7684rem;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-4xl: 32px;
--sombra-flutuante: 0 8px 24px -6px rgb(0 0 0 / 0.14), 0 2px 6px -2px rgb(0 0 0 / 0.08);
color: var(--ink);
font-family: var(--fonte-texto);
}
Na vitrine, a ponte com o Tailwind fica em src/themes/tokens.css:
/* Ponte entre os tokens do tema e o Tailwind.
Os componentes da biblioteca usam classes comuns do Tailwind (p-5, text-sm,
rounded-xl...) e as cores semânticas abaixo (bg-page, text-ink...). Dentro de
[data-theme="vault"], o tema redefine as variáveis que essas classes leem:
--spacing, --text-*, --radius-* e as cores. Os valores vêm do tema escolhido
na vitrine (src/themes/presets.ts) e são gerados por themeCss(). */
@theme inline {
--color-page: var(--page);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-muted: var(--muted);
--color-rule: var(--rule);
--color-rule-strong: var(--rule-strong);
--color-track: var(--track);
--color-grid: var(--grid);
--color-axis: var(--axis);
--color-accent: var(--accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-critical: var(--critical);
--font-display: var(--fonte-titulo);
--shadow-flutuante: var(--sombra-flutuante);
}