Skeleton
Uma forma de carregamento de placeholder que imita o layout do conteúdo antes que ele seja carregado, reduzindo o tempo de carregamento percebido e prevenindo a mudança de layout.
Busque em todas as páginas da documentação
Uma forma de carregamento de placeholder que imita o layout do conteúdo antes que ele seja carregado, reduzindo o tempo de carregamento percebido e prevenindo a mudança de layout.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
/>
);
}
// Uso
<Skeleton className="h-4 w-48" />Um único div com animate-pulse embutido do Tailwind e um fundo neutro. O consumidor controla totalmente a forma e o tamanho através de className. O atributo aria-hidden oculta o placeholder dos leitores de tela, pois ele não carrega conteúdo significativo.
interface SkeletonTextProps {
lines?: number;
}
export function SkeletonText({ lines = 3 }: SkeletonTextProps) {
return (
<div className="space-y-3" aria-hidden="true">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={`h-4 animate-pulse rounded-md bg-gray-200 ${
i === lines - 1 ? "w-2/3" : "w-full"
}`}
/>
))}
</div>
);
}Gera múltiplas linhas de texto de placeholder. A última linha é mais curta (w-2/3) para imitar como parágrafos reais geralmente terminam no meio da linha, o que torna o esqueleto mais natural.
type AvatarSize = "sm" | "md" | "lg";
interface SkeletonAvatarProps {
size?: AvatarSize;
}
const sizeClasses: Record<AvatarSize, string> = {
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-14 w-14",
};
export function SkeletonAvatar({ size = "md" }: SkeletonAvatarProps) {
return (
<div
className={`animate-pulse rounded-full bg-gray-200 ${sizeClasses[size]}`}
aria-hidden="true"
/>
);
}Um esqueleto circular que corresponde às dimensões comuns de avatares. Usar os mesmos valores de tamanho do componente Avatar real previne a mudança de layout quando o conteúdo é carregado.
export function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-200 p-4" aria-hidden="true">
<div className="h-40 animate-pulse rounded-lg bg-gray-200" />
<div className="mt-4 space-y-3">
<div className="h-5 w-3/4 animate-pulse rounded-md bg-gray-200" />
<div className="h-4 w-full animate-pulse rounded-md bg-gray-200" />
<div className="h-4 w-5/6 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="mt-4 flex items-center gap-3">
<div className="h-8 w-8 animate-pulse rounded-full bg-gray-200" />
<div className="h-4 w-24 animate-pulse rounded-md bg-gray-200" />
</div>
</div>
);
}Imita um layout de card típico com imagem, título, descrição e autor. A borda e o preenchimento correspondem ao card real para que o esqueleto ocupe exatamente o mesmo espaço, eliminando a mudança de layout ao carregar.
interface SkeletonTableProps {
rows?: number;
columns?: number;
}
export function SkeletonTable({ rows = 5, columns = 4 }: SkeletonTableProps) {
return (
<div className="w-full" aria-hidden="true">
<div className="flex gap-4 border-b border-gray-200 pb-3">
{Array.from({ length: columns }).map((_, i) => (
<div key={i} className="h-4 flex-1 animate-pulse rounded-md bg-gray-300" />
))}
</div>
{Array.from({ length: rows }).map((_, row) => (
<div key={row} className="flex gap-4 border-b border-gray-100 py-3">
{Array.from({ length: columns }).map((_, col) => (
<div key={col} className="h-4 flex-1 animate-pulse rounded-md bg-gray-200" />
))}
</div>
))}
</div>
);
}A linha de cabeçalho usa um tom ligeiramente mais escuro (bg-gray-300) para diferenciá-la das linhas de dados. Colunas flex com flex-1 distribuem uniformemente a largura para corresponder a um layout de tabela típico.
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`relative overflow-hidden rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/60 to-transparent" />
</div>
);
}Adicione isso ao seu tailwind.config.ts para registrar o keyframe de brilho:
// tailwind.config.ts
export default {
theme: {
extend: {
keyframes: {
shimmer: {
"100%": { transform: "translateX(100%)" },
},
},
},
},
};Um efeito de brilho que varre um gradiente de luz pelo placeholder. Isso parece mais polido do que o pulso padrão e dá aos usuários uma indicação mais forte de que o conteúdo está sendo carregado. O overflow-hidden impede que o gradiente vaze para fora dos cantos arredondados.
interface SkeletonProps {
className?: string;
}
function Skeleton({ className }: SkeletonProps) {
return (
<div
className={`animate-pulse rounded-md bg-gray-200 ${className ?? ""}`}
aria-hidden="true"
/>
);
}
export function ProfileSkeleton() {
return (
<div className="flex items-start gap-4" aria-hidden="true">
{/* Avatar */}
<Skeleton className="h-16 w-16 shrink-0 rounded-full" />
{/* Info */}
<div className="flex-1 space-y-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-56" />
<div className="flex gap-4 pt-1">
<Skeleton className="h-8 w-24 rounded-lg" />
<Skeleton className="h-8 w-24 rounded-lg" />
</div>
</div>
</div>
);
}Compõe o primitivo base Skeleton em um layout específico de perfil. Construir esqueletos específicos de domínio (ProfileSkeleton, CommentSkeleton, etc.) a partir de um único primitivo mantém a base de código DRY, garantindo ao mesmo tempo que cada esqueleto corresponda exatamente à sua contraparte real.
"use client";
import { useMemo } from "react";
type SkeletonVariant = "text" | "circular" | "rectangular" | "rounded";
interface SkeletonProps {
variant?: SkeletonVariant;
width?: string | number;
height?: string | number;
lines?: number;
animation?: "pulse" | "shimmer" | "none";
className?: string;
children?: React.ReactNode;
}
const variantClasses: Record<SkeletonVariant, string> = {
text: "rounded-md",
circular: "rounded-full",
rectangular: "rounded-none",
rounded: "rounded-xl",
};
function ShimmerOverlay() {
return (
<div className="absolute inset-0 -translate-x-full animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/60 to-transparent" />
);
}
export function Skeleton({
variant = "text",
width,
height,
lines,
animation = "pulse",
className,
children,
}: SkeletonProps) {
const style = useMemo(() => {
const s: React.CSSProperties = {};
if (width) s.width = typeof width === "number" ? `${width}px` : width;
if (height) s.height = typeof height === "number" ? `${height}px` : height;
return s;
}, [width, height]);
const animClass = animation === "pulse" ? "animate-pulse" : "";
const hasShimmer = animation === "shimmer";
// Esqueleto de texto com múltiplas linhas
if (lines && lines > 1) {
return (
<div className="space-y-3" aria-hidden="true" role="status">
<span className="sr-only">Carregando...</span>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={[
"bg-gray-200",
variantClasses.text,
animClass,
hasShimmer ? "relative overflow-hidden" : "",
i === lines - 1 ? "w-2/3" : "w-full",
]
.filter(Boolean)
.join(" ")}
style={{ height: typeof height === "number" ? height : 16 }}
>
{hasShimmer && <ShimmerOverlay />}
</div>
))}
</div>
);
}
// Esqueleto envolvendo conteúdo real (modo overlay)
if (children) {
return (
<div className="relative inline-flex" aria-hidden="true">
<div className="invisible">{children}</div>
<div
className={[
"absolute inset-0 bg-gray-200",
variantClasses[variant],
animClass,
hasShimmer ? "overflow-hidden" : "",
]
.filter(Boolean)
.join(" ")}
>
{hasShimmer && <ShimmerOverlay />}
</div>
</div>
);
}
// Bloco de esqueleto único
return (
<div aria-hidden="true" role="status">
<span className="sr-only">Carregando...</span>
<div
className={[
"bg-gray-200",
variantClasses[variant],
animClass,
hasShimmer ? "relative overflow-hidden" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
style={style}
>
{hasShimmer && <ShimmerOverlay />}
</div>
</div>
);
}
// --- Composições de Preset ---
export function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-200 p-4" role="status" aria-label="Carregando card">
<Skeleton variant="rounded" height={160} className="w-full" />
<div className="mt-4 space-y-3">
<Skeleton width="75%" height={20} />
<Skeleton height={16} />
<Skeleton width="85%" height={16} />
</div>
<div className="mt-4 flex items-center gap-3">
<Skeleton variant="circular" width={32} height={32} />
<Skeleton width={96} height={16} />
</div>
</div>
);
}
export function SkeletonTable({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<div className="w-full" role="status" aria-label="Carregando tabela">
<span className="sr-only">Carregando...</span>
<div className="flex gap-4 border-b border-gray-200 pb-3">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} height={16} className="flex-1 bg-gray-300" />
))}
</div>
{Array.from({ length: rows }).map((_, row) => (
<div key={row} className="flex gap-4 border-b border-gray-100 py-3">
{Array.from({ length: columns }).map((_, col) => (
<Skeleton key={col} height={16} className="flex-1" />
))}
</div>
))}
</div>
);
}Aspectos Chave:
pulse usa a animação embutida do Tailwind, shimmer adiciona um gradiente deslizante para uma sensação premium, e none desabilita a animação para preferências de movimento reduzido.lines gera múltiplas linhas de esqueleto com a última linha mais curta, correspondendo aos finais naturais de parágrafos.width e height aceitam tanto números (px) quanto strings (porcentagens, rem) via estilos inline, evitando a necessidade de gerar classes dinâmicas do Tailwind.role="status" com um texto "Carregando..." visualmente oculto informa aos leitores de tela que o conteúdo está carregando sem exibir texto na tela.SkeletonCard e SkeletonTable demonstram esqueletos reutilizáveis e específicos de domínio construídos a partir do primitivo base, mantendo a API flexível e conveniente.Mudança de layout quando o conteúdo carrega -- Se as dimensões do esqueleto não corresponderem ao conteúdo real, a página salta quando os dados chegam. Sempre meça e combine a altura e a largura do componente real.
Muitos elementos animate-pulse -- Dezenas de elementos pulsando em uma única página podem degradar o desempenho em dispositivos de baixo custo. Use um animate-pulse pai no container em vez de elementos individuais.
Keyframe de shimmer não registrado -- A animação de shimmer requer um keyframe customizado em tailwind.config.ts. Sem ele, o elemento gradiente permanece estaticamente transladado para fora da tela.
aria-hidden ou role ausentes -- Esqueletos sem aria-hidden="true" ou role="status" poluem a árvore de acessibilidade. Divs vazias são anunciadas como "grupo" por alguns leitores de tela, confundindo os usuários.
Esqueleto nunca desaparece -- Esquecer de renderizar condicionalmente o esqueleto vs. o conteúdo real significa que o placeholder permanece visível para sempre. Sempre controle a renderização do esqueleto com um estado de carregamento: {isLoading ? <Skeleton /> : <RealContent />}.
Preferências de movimento reduzido ignoradas -- Usuários com prefers-reduced-motion habilitado podem achar animações contínuas distrativas. Respeite isso com motion-safe:animate-pulse ou desabilite a animação completamente através da prop animation="none".
Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥