Esqueleto e Carregando
Esqueletos que pulsam na forma do conteúdo que vai chegar: bloco solto, linhas de lista ou texto, cartões de número, tabela e gráfico, numa região com aria-busy. Inclui o spinner com frase (Carregando) e o selo “Atualizando...” para recargas em segundo plano.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Esqueleto>
Um bloco que pulsa no lugar do conteúdo que ainda vai chegar. Monte a FORMA do conteúdo com vários deles, para a tela não pular quando os dados entrarem. O tamanho vem das classes (`className="h-6 w-1/3"`, que seguem a densidade do tema) ou de `largura`/`altura`. Com "reduzir movimento", fica parado.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| forma | "retangulo" | "texto" | "circulo" | "retangulo" | `retangulo` (h-4, largura total), `texto` (h-3, linha de texto) ou `circulo` (size-10, avatar e ícone). |
| largura | string | number | — | Largura em CSS ("40%", "12rem") ou em px. Sem ela, vale a da forma ou a do `className`. |
| altura | string | number | — | Altura em CSS ou em px. Sem ela, vale a da forma ou a do `className`. |
| className | string | "" |
<RegiaoCarregando>
A região que está carregando: marca `aria-busy` e dá a frase que o leitor de tela ouve, já que os blocos são `aria-hidden` (sem isso a região fica muda). Use para juntar vários esqueletos sob um anúncio só.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| rotulo | string | "Carregando..." | O que está carregando, para leitores de tela ("Carregando pedidos..."). |
| childrenobrigatória | ReactNode | — | |
| className | string | "flex flex-col gap-4" | Classes do contêiner; substituem o padrão (coluna com gap-4). |
<EsqueletoLinhas>
Linhas de esqueleto: `lista` (ícone redondo, nome, legenda e valor, com divisórias) ou `texto` (um parágrafo, a última linha mais curta).
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| linhas | number | 4 | |
| variante | "lista" | "texto" | "lista" | |
| rotulo | string | "Carregando..." | Frase para leitores de tela (ignorada dentro de uma RegiaoCarregando). |
<EsqueletoCartoes>
A faixa de cartões de número (KPIs) do topo de um painel: rótulo, valor grande e legenda.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| cartoes | number | 4 | |
| rotulo | string | "Carregando indicadores..." | Frase para leitores de tela (ignorada dentro de uma RegiaoCarregando). |
<EsqueletoTabela>
A forma de uma tabela: a faixa do cabeçalho e N linhas, com a primeira coluna mais larga. Diferente da lista: prometer a forma errada faz a tela pular do mesmo jeito.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| colunas | number | 4 | |
| linhas | number | 5 | |
| rotulo | string | "Carregando tabela..." | Frase para leitores de tela (ignorada dentro de uma RegiaoCarregando). |
<EsqueletoGrafico>
Um cartão de gráfico: título curto e a área do desenho.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| altura | string | number | — | Altura da área do gráfico em CSS ou px (padrão: h-56). |
| rotulo | string | "Carregando gráfico..." | Frase para leitores de tela (ignorada dentro de uma RegiaoCarregando). |
<Carregando>
Spinner e frase, centralizados. É a exceção, não a regra: use quando não há forma a prometer (resolvendo a rota, checando permissão). Se você sabe a forma do que vai aparecer, use um esqueleto.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| texto | string | "Carregando..." | |
| className | string | "py-16" |
<IndicadorAtualizando>
Selo de recarga em segundo plano: o conteúdo antigo continua na tela e isto diz que ele está sendo atualizado. Trocar a lista por esqueleto a cada recarga pisca a tela e perde a rolagem; separe "carregando" (primeira vez) de "atualizando".
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| texto | string | "Atualizando..." |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { createContext, useContext, type CSSProperties, type ReactNode } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
// Quando vários esqueletos estão dentro de uma RegiaoCarregando, só ela anuncia;
// sem isso, cada peça repetiria "Carregando..." para o leitor de tela.
const DentroDeRegiao = createContext(false);
const FORMAS = {
retangulo: { raio: "rounded-md", altura: "h-4", largura: "w-full" },
texto: { raio: "rounded-sm", altura: "h-3", largura: "w-full" },
circulo: { raio: "rounded-full", altura: "h-10", largura: "w-10" },
} as const;
// Sem tailwind-merge, `h-4` e `h-8` juntos não têm vencedor garantido: o tamanho
// padrão da forma só entra quando o className não traz o seu.
const TEM_ALTURA = /(^|\s)(h|size)-/;
const TEM_LARGURA = /(^|\s)(w|size)-/;
/**
* Um bloco que pulsa no lugar do conteúdo que ainda vai chegar. Monte a FORMA do
* conteúdo com vários deles, para a tela não pular quando os dados entrarem. O
* tamanho vem das classes (`className="h-6 w-1/3"`, que seguem a densidade do
* tema) ou de `largura`/`altura`. Com "reduzir movimento", fica parado.
*/
export function Esqueleto({
forma = "retangulo",
largura,
altura,
className = "",
}: {
/** `retangulo` (h-4, largura total), `texto` (h-3, linha de texto) ou `circulo` (size-10, avatar e ícone). */
forma?: keyof typeof FORMAS;
/** Largura em CSS ("40%", "12rem") ou em px. Sem ela, vale a da forma ou a do `className`. */
largura?: string | number;
/** Altura em CSS ou em px. Sem ela, vale a da forma ou a do `className`. */
altura?: string | number;
className?: string;
}) {
const semMovimento = useSemMovimento();
const f = FORMAS[forma];
const estilo: CSSProperties = {};
if (largura != null) estilo.width = largura;
if (altura != null) estilo.height = altura;
const alturaPadrao = altura == null && !TEM_ALTURA.test(className) ? f.altura : "";
const larguraPadrao = largura == null && !TEM_LARGURA.test(className) ? f.largura : "";
return (
<div
aria-hidden="true"
style={estilo}
className={`shrink-0 bg-ink/10 ${f.raio} ${alturaPadrao} ${larguraPadrao} ${semMovimento ? "" : "animate-pulse"} ${className}`}
/>
);
}
/**
* A região que está carregando: marca `aria-busy` e dá a frase que o leitor de
* tela ouve, já que os blocos são `aria-hidden` (sem isso a região fica muda).
* Use para juntar vários esqueletos sob um anúncio só.
*/
export function RegiaoCarregando({
rotulo = "Carregando...",
children,
className = "flex flex-col gap-4",
}: {
/** O que está carregando, para leitores de tela ("Carregando pedidos..."). */
rotulo?: string;
children: ReactNode;
/** Classes do contêiner; substituem o padrão (coluna com gap-4). */
className?: string;
}) {
return (
<div aria-busy="true" aria-live="polite" className={className}>
<span className="sr-only">{rotulo}</span>
<DentroDeRegiao.Provider value={true}>{children}</DentroDeRegiao.Provider>
</div>
);
}
/** Cada peça pronta é uma região própria, a menos que já esteja dentro de uma. */
function Regiao({ rotulo, className, children }: { rotulo: string; className?: string; children: ReactNode }) {
const dentro = useContext(DentroDeRegiao);
if (dentro) return <div className={className}>{children}</div>;
return (
<RegiaoCarregando rotulo={rotulo} className={className}>
{children}
</RegiaoCarregando>
);
}
// Larguras que variam de linha para linha, para não parecer uma grade.
const LARGURAS_DO_TITULO = ["w-5/12", "w-1/3", "w-1/2", "w-2/5", "w-4/12"];
const LARGURAS_DA_LEGENDA = ["w-1/4", "w-1/3", "w-1/5", "w-2/5", "w-1/4"];
const LARGURAS_DO_TEXTO = ["w-full", "w-11/12", "w-full", "w-10/12", "w-full", "w-9/12"];
/**
* Linhas de esqueleto: `lista` (ícone redondo, nome, legenda e valor, com
* divisórias) ou `texto` (um parágrafo, a última linha mais curta).
*/
export function EsqueletoLinhas({
linhas = 4,
variante = "lista",
rotulo = "Carregando...",
}: {
linhas?: number;
variante?: "lista" | "texto";
/** Frase para leitores de tela (ignorada dentro de uma RegiaoCarregando). */
rotulo?: string;
}) {
if (variante === "texto") {
return (
<Regiao rotulo={rotulo} className="flex flex-col gap-2.5">
{Array.from({ length: linhas }, (_, i) => (
<Esqueleto
key={i}
forma="texto"
className={i === linhas - 1 && linhas > 1 ? "w-3/5" : LARGURAS_DO_TEXTO[i % LARGURAS_DO_TEXTO.length]}
/>
))}
</Regiao>
);
}
return (
<Regiao rotulo={rotulo}>
{Array.from({ length: linhas }, (_, i) => (
<div key={i} className="flex items-center gap-3 border-t border-rule py-3 first:border-t-0">
<Esqueleto forma="circulo" className="size-8" />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<Esqueleto forma="texto" className={`h-3.5 ${LARGURAS_DO_TITULO[i % 5]}`} />
<Esqueleto forma="texto" className={LARGURAS_DA_LEGENDA[i % 5]} />
</div>
<Esqueleto forma="texto" className="h-3.5 w-16" />
</div>
))}
</Regiao>
);
}
/** A faixa de cartões de número (KPIs) do topo de um painel: rótulo, valor grande e legenda. */
export function EsqueletoCartoes({
cartoes = 4,
rotulo = "Carregando indicadores...",
}: {
cartoes?: number;
/** Frase para leitores de tela (ignorada dentro de uma RegiaoCarregando). */
rotulo?: string;
}) {
return (
<Regiao rotulo={rotulo} className="grid grid-cols-[repeat(auto-fit,minmax(11rem,1fr))] gap-4">
{Array.from({ length: cartoes }, (_, i) => (
<div key={i} className="flex flex-col gap-3 rounded-xl border border-rule bg-page p-5">
<Esqueleto forma="texto" className="w-1/2" />
<Esqueleto className="h-8 w-2/5" />
<Esqueleto forma="texto" className="w-3/4" />
</div>
))}
</Regiao>
);
}
const LARGURAS_DA_CELULA = ["w-3/4", "w-1/2", "w-2/3", "w-3/5"];
/**
* A forma de uma tabela: a faixa do cabeçalho e N linhas, com a primeira coluna
* mais larga. Diferente da lista: prometer a forma errada faz a tela pular do
* mesmo jeito.
*/
export function EsqueletoTabela({
colunas = 4,
linhas = 5,
rotulo = "Carregando tabela...",
}: {
colunas?: number;
linhas?: number;
/** Frase para leitores de tela (ignorada dentro de uma RegiaoCarregando). */
rotulo?: string;
}) {
const grade: CSSProperties = { gridTemplateColumns: `2fr repeat(${Math.max(colunas - 1, 0)}, minmax(0, 1fr))` };
return (
<Regiao rotulo={rotulo} className="w-full">
<div style={grade} className="grid items-center gap-4 border-b border-rule py-2.5">
{Array.from({ length: colunas }, (_, c) => (
<Esqueleto key={c} forma="texto" className={c === 0 ? "w-1/3" : "w-1/2"} />
))}
</div>
{Array.from({ length: linhas }, (_, l) => (
<div key={l} style={grade} className="grid items-center gap-4 border-b border-rule py-3.5 last:border-b-0">
{Array.from({ length: colunas }, (_, c) => (
<Esqueleto key={c} forma="texto" className={`h-3.5 ${LARGURAS_DA_CELULA[(l + c) % LARGURAS_DA_CELULA.length]}`} />
))}
</div>
))}
</Regiao>
);
}
/** Um cartão de gráfico: título curto e a área do desenho. */
export function EsqueletoGrafico({
altura,
rotulo = "Carregando gráfico...",
}: {
/** Altura da área do gráfico em CSS ou px (padrão: h-56). */
altura?: string | number;
/** Frase para leitores de tela (ignorada dentro de uma RegiaoCarregando). */
rotulo?: string;
}) {
return (
<Regiao rotulo={rotulo} className="flex flex-col gap-4 rounded-xl border border-rule bg-page p-5">
<Esqueleto forma="texto" className="h-3.5 w-1/3" />
<Esqueleto altura={altura} className={altura == null ? "h-56" : ""} />
</Regiao>
);
}
function Arco({ className }: { className: string }) {
const semMovimento = useSemMovimento();
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className={`shrink-0 ${semMovimento ? "" : "animate-spin"} ${className}`}
fill="none"
strokeWidth={2.5}
strokeLinecap="round"
>
<circle cx="12" cy="12" r="9" className="stroke-rule" />
<path d="M12 3a9 9 0 0 1 9 9" className="stroke-accent" />
</svg>
);
}
/**
* Spinner e frase, centralizados. É a exceção, não a regra: use quando não há
* forma a prometer (resolvendo a rota, checando permissão). Se você sabe a forma
* do que vai aparecer, use um esqueleto.
*/
export function Carregando({ texto = "Carregando...", className = "py-16" }: { texto?: string; className?: string }) {
return (
<div role="status" className={`flex flex-col items-center justify-center gap-3 ${className}`}>
<Arco className="size-8" />
<p className="text-sm text-ink-2">{texto}</p>
</div>
);
}
/**
* Selo de recarga em segundo plano: o conteúdo antigo continua na tela e isto
* diz que ele está sendo atualizado. Trocar a lista por esqueleto a cada recarga
* pisca a tela e perde a rolagem; separe "carregando" (primeira vez) de
* "atualizando".
*/
export function IndicadorAtualizando({ texto = "Atualizando..." }: { texto?: string }) {
return (
<span role="status" className="inline-flex items-center gap-1.5 text-xs whitespace-nowrap text-ink-2">
<Arco className="size-3.5" />
{texto}
</span>
);
}
import { defineStories } from "@/registry/stories";
import { ProvedorDeMovimento } from "@/library/revelar/Movimento";
import {
Carregando,
Esqueleto,
EsqueletoCartoes,
EsqueletoGrafico,
EsqueletoLinhas,
EsqueletoTabela,
IndicadorAtualizando,
RegiaoCarregando,
} from "./Esqueleto";
const TAREFAS = [
{ titulo: "Revisar proposta comercial", responsavel: "Ana Souza", prazo: "Hoje" },
{ titulo: "Atualizar planilha de custos", responsavel: "Bruno Lima", prazo: "Amanhã" },
{ titulo: "Preparar apresentação do trimestre", responsavel: "Carla Dias", prazo: "Sexta" },
];
function Peca({ tipo, quantidade }: { tipo: string; quantidade: number }) {
switch (tipo) {
case "linhas (texto)":
return <EsqueletoLinhas variante="texto" linhas={quantidade} />;
case "cartões":
return <EsqueletoCartoes cartoes={quantidade} />;
case "tabela":
return <EsqueletoTabela linhas={quantidade} />;
case "gráfico":
return <EsqueletoGrafico />;
case "carregando":
return <Carregando />;
default:
return <EsqueletoLinhas linhas={quantidade} />;
}
}
export default defineStories({
args: { tipo: "linhas (lista)", quantidade: 4, pulsar: true },
controls: {
tipo: {
type: "select",
label: "peça",
options: ["linhas (lista)", "linhas (texto)", "cartões", "tabela", "gráfico", "carregando"],
},
quantidade: { type: "number", label: "linhas / cartões", min: 1, max: 10 },
pulsar: { type: "boolean", label: "animar (desligado = ProvedorDeMovimento)" },
},
render: (a) => (
<div className="w-full max-w-2xl">
<ProvedorDeMovimento reduzido={!a.pulsar}>
<Peca tipo={a.tipo} quantidade={a.quantidade} />
</ProvedorDeMovimento>
</div>
),
stories: [
{ name: "Padrão" },
{
name: "Painel carregando",
render: () => (
<div className="w-full max-w-4xl">
<RegiaoCarregando rotulo="Carregando o painel de vendas...">
<EsqueletoCartoes cartoes={3} />
<div className="rounded-xl border border-rule bg-page px-5 py-2">
<EsqueletoTabela colunas={4} linhas={4} />
</div>
</RegiaoCarregando>
</div>
),
},
{
name: "Lista e gráfico",
render: () => (
<div className="grid w-full max-w-4xl gap-4 md:grid-cols-2">
<div className="rounded-xl border border-rule bg-page px-5 py-2">
<EsqueletoLinhas linhas={4} rotulo="Carregando tarefas..." />
</div>
<EsqueletoGrafico altura="12rem" />
</div>
),
},
{
name: "Forma própria com Esqueleto",
render: () => (
<div
className="flex w-full max-w-md items-start gap-4 rounded-xl border border-rule bg-page p-5"
aria-busy="true"
>
<span className="sr-only">Carregando perfil...</span>
<Esqueleto forma="circulo" className="size-14" />
<div className="flex flex-1 flex-col gap-2.5 pt-1">
<Esqueleto forma="texto" className="h-4 w-1/2" />
<Esqueleto forma="texto" className="w-1/3" />
<Esqueleto className="mt-2 h-9 w-32" />
</div>
</div>
),
},
{
name: "Atualizando em segundo plano",
render: () => (
<div className="w-full max-w-xl rounded-xl border border-rule bg-page p-5">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="font-display text-lg font-bold">Tarefas da semana</h3>
<IndicadorAtualizando />
</div>
<ul>
{TAREFAS.map((t) => (
<li
key={t.titulo}
className="flex items-center justify-between gap-3 border-t border-rule py-3 text-sm first:border-t-0"
>
<span>
{t.titulo}
<span className="block text-xs text-muted">{t.responsavel}</span>
</span>
<span className="text-ink-2">{t.prazo}</span>
</li>
))}
</ul>
</div>
),
},
{
name: "Carregando (spinner)",
render: () => (
<div className="w-full max-w-md rounded-xl border border-rule bg-page">
<Carregando texto="Verificando permissões..." />
</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.
Componentes da biblioteca (diretos e indiretos)
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);
}