Avatar
Foto de uma pessoa ou, sem ela, as iniciais sobre uma cor tirada do nome (a mesma pessoa tem sempre a mesma cor), em cinco tamanhos e com ponto de status opcional. GrupoDeAvatares sobrepõe vários e resume o resto em “+N”.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Avatar>
Foto de uma pessoa ou, sem foto (ou se ela não carregar), as iniciais sobre uma cor tirada do nome, para a mesma pessoa ter sempre a mesma cor. Ponto de status opcional no canto.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| nomeobrigatória | string | — | Nome da pessoa: vira as iniciais, a cor e o nome para leitores de tela. |
| imagem | string | — | Endereço da foto. |
| tamanho | "sm" | "md" | "xs" | "lg" | "xl" | "md" | |
| status | "online" | "ausente" | "ocupado" | "offline" | — | Ponto de status no canto inferior direito. |
| rotuloDoStatus | string | — | Texto do status para leitores de tela (padrão: "online", "ausente", "ocupado", "offline"). |
| cor | string | — | Cor de fundo das iniciais, no lugar da tirada do nome (`var(--series-2)`, `var(--accent)`). |
| decorativo | boolean | false | Esconde dos leitores de tela, quando o nome já aparece escrito ao lado. |
| className | string | "" |
<GrupoDeAvatares>
Avatares sobrepostos (os responsáveis de uma tarefa, quem está online): mostra até `maximo` e resume o resto em "+N", com os nomes que sobraram ao passar o mouse.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| pessoasobrigatória | readonly { nome: string; imagem?: string; }[] | — | |
| maximo | number | 4 | Quantos avatares aparecem antes do "+N". |
| tamanho | "sm" | "md" | "xs" | "lg" | "xl" | "sm" | |
| rotulo | string | — | Nome do grupo para leitores de tela ("Responsáveis"). |
| rotuloDoExcedente | (quantidade: number) => string | (n) => `e mais ${n}` | Texto do "+N" para leitores de tela. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useState } from "react";
/**
* Iniciais de um nome: primeira e última palavra ("Ana Paula Souza" → "AS"); com
* uma palavra só, as duas primeiras letras ("Marketing" → "MA").
*/
export function iniciais(nome: string) {
const partes = nome.trim().split(/\s+/).filter(Boolean);
if (partes.length === 0) return "?";
if (partes.length === 1) return partes[0].slice(0, 2).toUpperCase();
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
}
/** Uma das 8 cores de série do tema, sempre a mesma para o mesmo nome. */
export function corDoNome(nome: string) {
let hash = 0;
for (const letra of nome.trim().toLowerCase()) hash = (hash * 31 + letra.charCodeAt(0)) | 0;
return `var(--series-${(Math.abs(hash) % 8) + 1})`;
}
const TAMANHOS = {
xs: { caixa: "size-6 text-xs", ponto: "size-2", sobrepor: "-ml-1.5" },
sm: { caixa: "size-8 text-xs", ponto: "size-2.5", sobrepor: "-ml-2" },
md: { caixa: "size-10 text-sm", ponto: "size-3", sobrepor: "-ml-2.5" },
lg: { caixa: "size-14 text-lg", ponto: "size-3.5", sobrepor: "-ml-3.5" },
xl: { caixa: "size-20 text-2xl", ponto: "size-4", sobrepor: "-ml-5" },
} as const;
const STATUS = {
online: { cor: "bg-success", rotulo: "online" },
ausente: { cor: "bg-warning", rotulo: "ausente" },
ocupado: { cor: "bg-critical", rotulo: "ocupado" },
offline: { cor: "bg-muted", rotulo: "offline" },
} as const;
export type TamanhoDeAvatar = keyof typeof TAMANHOS;
export type StatusDoAvatar = keyof typeof STATUS;
/**
* Foto de uma pessoa ou, sem foto (ou se ela não carregar), as iniciais sobre uma
* cor tirada do nome, para a mesma pessoa ter sempre a mesma cor. Ponto de status
* opcional no canto.
*/
export function Avatar({
nome,
imagem,
tamanho = "md",
status,
rotuloDoStatus,
cor,
decorativo = false,
className = "",
}: {
/** Nome da pessoa: vira as iniciais, a cor e o nome para leitores de tela. */
nome: string;
/** Endereço da foto. */
imagem?: string;
tamanho?: TamanhoDeAvatar;
/** Ponto de status no canto inferior direito. */
status?: StatusDoAvatar;
/** Texto do status para leitores de tela (padrão: "online", "ausente", "ocupado", "offline"). */
rotuloDoStatus?: string;
/** Cor de fundo das iniciais, no lugar da tirada do nome (`var(--series-2)`, `var(--accent)`). */
cor?: string;
/** Esconde dos leitores de tela, quando o nome já aparece escrito ao lado. */
decorativo?: boolean;
className?: string;
}) {
// Guarda QUAL endereço falhou: se a foto mudar, tenta de novo sem precisar de efeito.
const [falhou, setFalhou] = useState<string | null>(null);
const t = TAMANHOS[tamanho];
const comFoto = imagem && falhou !== imagem;
const textoDoStatus = status ? (rotuloDoStatus ?? STATUS[status].rotulo) : null;
const nomeAcessivel = textoDoStatus ? `${nome} (${textoDoStatus})` : nome;
return (
<span
role={decorativo ? undefined : "img"}
aria-label={decorativo ? undefined : nomeAcessivel}
aria-hidden={decorativo || undefined}
title={nome}
className={`relative inline-flex shrink-0 rounded-full ${t.caixa} ${className}`}
>
{comFoto ? (
// <img> comum para funcionar em qualquer projeto React (sem next/image).
// eslint-disable-next-line @next/next/no-img-element
<img
src={imagem}
alt=""
onError={() => setFalhou(imagem)}
className="size-full rounded-full bg-surface object-cover"
/>
) : (
<span
aria-hidden="true"
style={{ backgroundColor: cor ?? corDoNome(nome) }}
className="flex size-full items-center justify-center rounded-full leading-none font-semibold tracking-wide text-page select-none"
>
{iniciais(nome)}
</span>
)}
{status && (
<span
aria-hidden="true"
className={`absolute right-0 bottom-0 rounded-full ring-2 ring-page ${t.ponto} ${STATUS[status].cor}`}
/>
)}
</span>
);
}
/**
* Avatares sobrepostos (os responsáveis de uma tarefa, quem está online): mostra
* até `maximo` e resume o resto em "+N", com os nomes que sobraram ao passar o mouse.
*/
export function GrupoDeAvatares({
pessoas,
maximo = 4,
tamanho = "sm",
rotulo,
rotuloDoExcedente = (n) => `e mais ${n}`,
}: {
pessoas: ReadonlyArray<{ nome: string; imagem?: string }>;
/** Quantos avatares aparecem antes do "+N". */
maximo?: number;
tamanho?: TamanhoDeAvatar;
/** Nome do grupo para leitores de tela ("Responsáveis"). */
rotulo?: string;
/** Texto do "+N" para leitores de tela. */
rotuloDoExcedente?: (quantidade: number) => string;
}) {
// Com um só a mais, cabe o avatar dele no lugar do "+1".
const visiveis = pessoas.length <= maximo + 1 ? pessoas : pessoas.slice(0, maximo);
const resto = pessoas.slice(visiveis.length);
const t = TAMANHOS[tamanho];
return (
<div role="group" aria-label={rotulo} className="flex items-center">
{visiveis.map((p, i) => (
<Avatar
key={`${p.nome}-${i}`}
nome={p.nome}
imagem={p.imagem}
tamanho={tamanho}
className={`ring-2 ring-page ${i > 0 ? t.sobrepor : ""}`}
/>
))}
{resto.length > 0 && (
<span
role="img"
aria-label={rotuloDoExcedente(resto.length)}
title={resto.map((p) => p.nome).join(", ")}
className={`${t.sobrepor} inline-flex shrink-0 items-center justify-center rounded-full bg-surface font-medium text-ink-2 ring-2 ring-page ${t.caixa}`}
>
<span aria-hidden="true">+{resto.length}</span>
</span>
)}
</div>
);
}
import { defineStories } from "@/registry/stories";
import { Avatar, GrupoDeAvatares, type StatusDoAvatar, type TamanhoDeAvatar } from "./Avatar";
// "Foto" de exemplo desenhada em SVG, para o preview não depender de rede. Fundo
// transparente (aparece o bg-surface do <img>) e silhueta em preto translúcido.
const FOTO = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="currentColor" fill-opacity="0.35"><circle cx="32" cy="25" r="12"/><path d="M10 64c2-14 11-21 22-21s20 7 22 21z"/></svg>',
)}`;
const EQUIPE = [
{ nome: "Ana Souza" },
{ nome: "Bruno Lima", imagem: FOTO },
{ nome: "Carla Dias" },
{ nome: "Diego Martins" },
{ nome: "Elisa Rocha" },
{ nome: "Fábio Nunes" },
{ nome: "Gabriela Costa" },
];
const TAMANHOS: TamanhoDeAvatar[] = ["xs", "sm", "md", "lg", "xl"];
export default defineStories({
args: {
mostrar: "avatar",
nome: "Mariana Albuquerque",
comFoto: false,
tamanho: "lg",
status: "online",
quantidade: 7,
maximo: 4,
},
controls: {
mostrar: { type: "select", label: "componente", options: ["avatar", "grupo"] },
nome: { type: "text", label: "nome (Avatar)" },
comFoto: { type: "boolean", label: "imagem (Avatar)" },
tamanho: { type: "select", options: TAMANHOS },
status: {
type: "select",
label: "status (Avatar)",
options: ["nenhum", "online", "ausente", "ocupado", "offline"],
},
quantidade: { type: "number", label: "pessoas (GrupoDeAvatares)", min: 1, max: 7 },
maximo: { type: "number", label: "maximo (GrupoDeAvatares)", min: 1, max: 7 },
},
// Um componente por vez: o Avatar sozinho ou o GrupoDeAvatares
render: (a) =>
a.mostrar === "grupo" ? (
<GrupoDeAvatares
rotulo="Responsáveis"
pessoas={EQUIPE.slice(0, a.quantidade)}
maximo={a.maximo}
tamanho={a.tamanho as TamanhoDeAvatar}
/>
) : (
<Avatar
nome={a.nome}
imagem={a.comFoto ? FOTO : undefined}
tamanho={a.tamanho as TamanhoDeAvatar}
status={a.status === "nenhum" ? undefined : (a.status as StatusDoAvatar)}
/>
),
stories: [
{ name: "Avatar" },
{ name: "Grupo", args: { mostrar: "grupo" } },
{
name: "Avatares e grupo",
render: () => (
<div className="flex flex-col items-start gap-6">
<div className="flex items-center gap-3">
<Avatar nome="Ana Souza" status="online" />
<Avatar nome="Bruno Lima" imagem={FOTO} status="ausente" />
<Avatar nome="Carla Dias" status="ocupado" />
<Avatar nome="Diego Martins" status="offline" />
<Avatar nome="Marketing" />
</div>
<GrupoDeAvatares rotulo="Responsáveis" pessoas={EQUIPE} tamanho="md" />
</div>
),
},
{
name: "Tamanhos",
render: () => (
<div className="flex items-end gap-3">
{TAMANHOS.map((t) => (
<Avatar key={t} nome="Ana Souza" tamanho={t} status="online" />
))}
</div>
),
},
{
name: "Ao lado do nome",
render: () => (
<ul className="flex w-full max-w-xs flex-col gap-3">
{EQUIPE.slice(0, 4).map((p) => (
<li key={p.nome} className="flex items-center gap-3 text-sm">
<Avatar nome={p.nome} imagem={p.imagem} tamanho="sm" decorativo />
<span>{p.nome}</span>
</li>
))}
</ul>
),
},
{
name: "Foto que não carrega",
render: () => (
<div className="flex items-center gap-3 text-sm text-ink-2">
<Avatar nome="Rafael Teixeira" imagem="/nao-existe.jpg" tamanho="lg" />
<span>Sem a foto, ficam as iniciais.</span>
</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);
}