Notificações
Avisos passageiros (toasts) sem dependências: renderize o HospedeiroDeNotificacoes uma vez e chame notificar() ou notificar.sucesso/erro/aviso/info de qualquer lugar. Empilha, pausa com o mouse ou o foco, tem ação e botão de fechar, e anuncia por aria-live.
Esta variação é uma composição fixa. Escolha outra para mexer nas props no playground.
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Notificacao>
Uma notificação, só o visual: ícone e borda na cor do tipo, título, descrição, ação e botão de fechar. O HospedeiroDeNotificacoes usa este cartão; use-o direto para mostrar exemplos parados.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| tipo | TipoDeNotificacao | "info" | |
| tituloobrigatória | string | — | |
| descricao | string | — | |
| acao | { rotulo: string; aoClicar: () => void; } | — | |
| aoFechar | () => void | — | Sem esta função, o botão de fechar não aparece. |
| rotuloFechar | string | "Fechar notificação" | Nome do botão × para leitores de tela. |
<HospedeiroDeNotificacoes>
Onde as notificações de `notificar()` aparecem: renderize uma vez (no layout). Empilha as mais recentes junto à borda, pausa os prazos com o mouse ou o foco em cima, anuncia erros na hora e as demais com educação (aria-live). Com "reduzir movimento", entram e saem sem transição.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| posicao | PosicaoDasNotificacoes | "inferior-direita" | |
| contido | boolean | false | Posição absoluta dentro do ancestral com `relative`, em vez de fixa na janela. |
| maximo | number | 4 | Quantas ficam visíveis ao mesmo tempo; as mais antigas esperam escondidas. |
| rotulo | string | "Notificações" | Nome da região para leitores de tela. |
| rotuloFechar | string | "Fechar notificação" |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useEffect, useRef, useSyncExternalStore } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
export type TipoDeNotificacao = "sucesso" | "erro" | "aviso" | "info";
export type OpcoesDeNotificacao = {
/** Cor, ícone e prioridade do anúncio (padrão: "info"). Erros são anunciados na hora (aria-live assertive). */
tipo?: TipoDeNotificacao;
titulo: string;
descricao?: string;
/** Tempo (ms) até sumir sozinha; 0 deixa fixa até alguém fechar. Padrão: 5000. */
duracao?: number;
/** Botão dentro da notificação ("Desfazer", "Ver"); fecha a notificação depois de rodar. */
acao?: { rotulo: string; aoClicar: () => void };
/** Repetir um id substitui a notificação que já está na tela (ex.: "Salvando..." vira "Salvo"). */
id?: string;
};
type NotificacaoAtiva = OpcoesDeNotificacao & {
id: string;
tipo: TipoDeNotificacao;
duracao: number;
saindo: boolean;
};
const DURACAO_PADRAO = 5000;
/** Tempo da transição de saída antes de a notificação sair da lista. */
const DURACAO_SAIDA = 200;
// ─── Emissor no nível do módulo ────────────────────────────────────────────────
// `notificar()` pode ser chamado de qualquer lugar (inclusive fora de componentes);
// o HospedeiroDeNotificacoes assina a lista. Os prazos também ficam aqui, para as
// notificações sumirem mesmo sem nenhum hospedeiro montado.
let lista: NotificacaoAtiva[] = [];
const VAZIA: NotificacaoAtiva[] = [];
const ouvintes = new Set<() => void>();
const prazos = new Map<string, { espera?: number; restante: number; inicio: number }>();
let pausas = 0;
let proximoId = 1;
function publicar(nova: NotificacaoAtiva[]) {
lista = nova;
for (const ouvinte of ouvintes) ouvinte();
}
function assinar(ouvinte: () => void) {
ouvintes.add(ouvinte);
return () => {
ouvintes.delete(ouvinte);
};
}
const lerLista = () => lista;
const lerVazia = () => VAZIA;
function cancelarPrazo(id: string) {
const prazo = prazos.get(id);
if (prazo?.espera !== undefined) window.clearTimeout(prazo.espera);
prazos.delete(id);
}
function agendar(id: string, ms: number) {
prazos.set(id, {
restante: ms,
inicio: Date.now(),
espera: pausas > 0 ? undefined : window.setTimeout(() => fecharNotificacao(id), ms),
});
}
/** Congela os prazos (mouse ou foco sobre as notificações). Cada chamada pede um `retomar`. */
function pausar() {
pausas += 1;
if (pausas > 1) return;
for (const prazo of prazos.values()) {
if (prazo.espera === undefined) continue;
window.clearTimeout(prazo.espera);
prazo.espera = undefined;
prazo.restante -= Date.now() - prazo.inicio;
}
}
function retomar() {
pausas = Math.max(0, pausas - 1);
if (pausas > 0) return;
for (const [id, prazo] of prazos) {
prazo.inicio = Date.now();
prazo.espera = window.setTimeout(() => fecharNotificacao(id), Math.max(prazo.restante, 0));
}
}
/**
* Mostra uma notificação em todo HospedeiroDeNotificacoes montado e devolve o id
* dela (para fechar ou substituir depois). Atalhos: `notificar.sucesso`, `.erro`,
* `.aviso` e `.info`, que recebem o título e as demais opções.
*/
export function notificar(opcoes: OpcoesDeNotificacao): string {
const id = opcoes.id ?? `notificacao-${proximoId++}`;
const nova: NotificacaoAtiva = {
...opcoes,
id,
tipo: opcoes.tipo ?? "info",
duracao: opcoes.duracao ?? DURACAO_PADRAO,
saindo: false,
};
cancelarPrazo(id);
const existe = lista.some((n) => n.id === id);
publicar(existe ? lista.map((n) => (n.id === id ? nova : n)) : [...lista, nova]);
if (nova.duracao > 0) agendar(id, nova.duracao);
return id;
}
type Atalho = (titulo: string, opcoes?: Omit<OpcoesDeNotificacao, "tipo" | "titulo">) => string;
const atalho =
(tipo: TipoDeNotificacao): Atalho =>
(titulo, opcoes) =>
notificar({ ...opcoes, titulo, tipo });
notificar.sucesso = atalho("sucesso");
notificar.erro = atalho("erro");
notificar.aviso = atalho("aviso");
notificar.info = atalho("info");
/** Fecha a notificação com esse id, ou todas quando chamada sem id. */
export function fecharNotificacao(id?: string) {
const alvos = new Set(lista.filter((n) => !n.saindo && (id === undefined || n.id === id)).map((n) => n.id));
if (alvos.size === 0) return;
for (const alvo of alvos) cancelarPrazo(alvo);
publicar(lista.map((n) => (alvos.has(n.id) ? { ...n, saindo: true } : n)));
// Sai da lista depois da transição; se o mesmo id foi notificado de novo nesse meio-tempo, a nova fica
window.setTimeout(() => publicar(lista.filter((n) => !(alvos.has(n.id) && n.saindo))), DURACAO_SAIDA);
}
// ─── Visual ────────────────────────────────────────────────────────────────────
const ESTILO = {
sucesso: { cor: "text-success", borda: "border-l-success" },
erro: { cor: "text-critical", borda: "border-l-critical" },
aviso: { cor: "text-warning", borda: "border-l-warning" },
info: { cor: "text-accent", borda: "border-l-accent" },
} as const;
function Icone({ tipo, className }: { tipo: TipoDeNotificacao; className: string }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className={className}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{tipo === "aviso" ? (
<>
<path d="M12 3 2.5 20h19L12 3z" />
<path d="M12 10v4" />
<path d="M12 17.5h.01" />
</>
) : (
<circle cx="12" cy="12" r="9" />
)}
{tipo === "sucesso" && <path d="m8 12.5 2.5 2.5L16 9.5" />}
{tipo === "erro" && <path d="m9 9 6 6m0-6-6 6" />}
{tipo === "info" && (
<>
<path d="M12 11v5" />
<path d="M12 7.5h.01" />
</>
)}
</svg>
);
}
/**
* Uma notificação, só o visual: ícone e borda na cor do tipo, título, descrição,
* ação e botão de fechar. O HospedeiroDeNotificacoes usa este cartão; use-o direto
* para mostrar exemplos parados.
*/
export function Notificacao({
tipo = "info",
titulo,
descricao,
acao,
aoFechar,
rotuloFechar = "Fechar notificação",
}: {
tipo?: TipoDeNotificacao;
titulo: string;
descricao?: string;
acao?: { rotulo: string; aoClicar: () => void };
/** Sem esta função, o botão de fechar não aparece. */
aoFechar?: () => void;
/** Nome do botão × para leitores de tela. */
rotuloFechar?: string;
}) {
const estilo = ESTILO[tipo];
return (
<div
className={`flex w-full items-start gap-3 rounded-lg border border-l-4 border-rule ${estilo.borda} bg-page p-3.5 text-sm text-ink shadow-flutuante`}
>
<Icone tipo={tipo} className={`mt-px size-5 shrink-0 ${estilo.cor}`} />
<div className="min-w-0 flex-1">
<p className="font-medium">{titulo}</p>
{descricao && <p className="mt-0.5 text-xs text-ink-2">{descricao}</p>}
{acao && (
<button
type="button"
onClick={acao.aoClicar}
className="mt-2.5 inline-flex min-h-8 cursor-pointer items-center rounded-[max(var(--radius-sm),calc(var(--radius-lg)_-_var(--spacing)*3.5))] bg-accent px-3 text-xs font-semibold text-page outline-accent hover:bg-accent/85 focus-visible:outline-2 focus-visible:outline-offset-2"
>
{acao.rotulo}
</button>
)}
</div>
{aoFechar && (
<button
type="button"
onClick={aoFechar}
aria-label={rotuloFechar}
className="-mt-0.5 -mr-0.5 grid size-7 shrink-0 cursor-pointer place-items-center rounded-[max(var(--radius-sm),calc(var(--radius-lg)_-_var(--spacing)*3))] border border-rule text-ink-2 outline-accent transition-colors hover:bg-surface hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1"
>
<svg aria-hidden="true" viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
)}
</div>
);
}
// ─── Hospedeiro ────────────────────────────────────────────────────────────────
export type PosicaoDasNotificacoes =
| "superior-esquerda"
| "superior-centro"
| "superior-direita"
| "inferior-esquerda"
| "inferior-centro"
| "inferior-direita";
const POSICOES: Record<PosicaoDasNotificacoes, string> = {
"superior-esquerda": "top-0 left-0 items-start",
"superior-centro": "top-0 left-1/2 -translate-x-1/2 items-center",
"superior-direita": "top-0 right-0 items-end",
"inferior-esquerda": "bottom-0 left-0 items-start",
"inferior-centro": "bottom-0 left-1/2 -translate-x-1/2 items-center",
"inferior-direita": "bottom-0 right-0 items-end",
};
/**
* Onde as notificações de `notificar()` aparecem: renderize uma vez (no layout).
* Empilha as mais recentes junto à borda, pausa os prazos com o mouse ou o foco
* em cima, anuncia erros na hora e as demais com educação (aria-live). Com
* "reduzir movimento", entram e saem sem transição.
*/
export function HospedeiroDeNotificacoes({
posicao = "inferior-direita",
contido = false,
maximo = 4,
rotulo = "Notificações",
rotuloFechar = "Fechar notificação",
}: {
posicao?: PosicaoDasNotificacoes;
/** Posição absoluta dentro do ancestral com `relative`, em vez de fixa na janela. */
contido?: boolean;
/** Quantas ficam visíveis ao mesmo tempo; as mais antigas esperam escondidas. */
maximo?: number;
/** Nome da região para leitores de tela. */
rotulo?: string;
rotuloFechar?: string;
}) {
const notificacoes = useSyncExternalStore(assinar, lerLista, lerVazia);
const semMovimento = useSemMovimento();
const noTopo = posicao.startsWith("superior");
// Sem transição, quem está saindo some na hora
const visiveis = (semMovimento ? notificacoes.filter((n) => !n.saindo) : notificacoes).slice(-maximo);
// A mais recente fica junto à borda: embaixo nas posições inferiores, em cima nas superiores
const emOrdem = noTopo ? [...visiveis].reverse() : visiveis;
// Mouse e foco pausam os prazos por motivos independentes; ao desmontar, devolve o que segurava
const segurando = useRef({ mouse: false, foco: false });
const segurar = (motivo: "mouse" | "foco", sim: boolean) => {
const estado = segurando.current;
if (estado[motivo] === sim) return;
estado[motivo] = sim;
if (sim) pausar();
else retomar();
};
useEffect(() => {
const estado = segurando.current;
return () => {
if (estado.mouse) retomar();
if (estado.foco) retomar();
estado.mouse = false;
estado.foco = false;
};
}, []);
const deslocamento = noTopo ? "-translate-y-3" : "translate-y-3";
const transicao = semMovimento
? ""
: `transition-[opacity,translate] duration-200 ease-out starting:opacity-0 ${noTopo ? "starting:-translate-y-3" : "starting:translate-y-3"}`;
return (
<section
aria-label={rotulo}
aria-live="polite"
aria-relevant="additions text"
className={`${contido ? "absolute" : "fixed"} pointer-events-none z-50 flex w-full max-w-sm flex-col p-4 ${POSICOES[posicao]}`}
>
{/* A lista recebe o mouse (os vãos entre as notificações não retomam o prazo); a região em volta não */}
<ol
className="pointer-events-auto flex w-full flex-col gap-2"
onMouseEnter={() => segurar("mouse", true)}
onMouseLeave={() => segurar("mouse", false)}
onFocus={() => segurar("foco", true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) segurar("foco", false);
}}
>
{emOrdem.map((n) => (
<li
key={n.id}
role={n.tipo === "erro" ? "alert" : "status"}
aria-live={n.tipo === "erro" ? "assertive" : "polite"}
aria-atomic="true"
className={`${transicao} ${n.saindo ? `opacity-0 ${deslocamento}` : ""}`}
>
<Notificacao
tipo={n.tipo}
titulo={n.titulo}
descricao={n.descricao}
rotuloFechar={rotuloFechar}
aoFechar={() => fecharNotificacao(n.id)}
acao={
n.acao && {
rotulo: n.acao.rotulo,
aoClicar: () => {
n.acao?.aoClicar();
fecharNotificacao(n.id);
},
}
}
/>
</li>
))}
</ol>
</section>
);
}
import { defineStories } from "@/registry/stories";
import {
fecharNotificacao,
HospedeiroDeNotificacoes,
Notificacao,
notificar,
type PosicaoDasNotificacoes,
type TipoDeNotificacao,
} from "./Notificacoes";
const EXEMPLOS: Record<TipoDeNotificacao, { titulo: string; descricao: string }> = {
sucesso: { titulo: "Pedido enviado", descricao: "O cliente recebe a confirmação por e-mail." },
erro: { titulo: "Não foi possível salvar", descricao: "Verifique a conexão e tente de novo." },
aviso: { titulo: "Estoque baixo", descricao: "Restam 3 unidades do Moedor manual." },
info: { titulo: "Relatório em preparo", descricao: "Avisamos quando o arquivo estiver pronto." },
};
const ROTULOS: Record<TipoDeNotificacao, string> = { sucesso: "Sucesso", erro: "Erro", aviso: "Aviso", info: "Info" };
const BOTAO =
"inline-flex min-h-10 cursor-pointer items-center rounded-lg border border-rule bg-page px-4 text-sm font-medium text-ink outline-accent hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2";
function Exemplo({
posicao,
contido,
duracao,
comDescricao,
comAcao,
}: {
posicao: PosicaoDasNotificacoes;
contido: boolean;
duracao: number;
comDescricao: boolean;
comAcao: boolean;
}) {
const disparar = (tipo: TipoDeNotificacao) =>
notificar({
tipo,
titulo: EXEMPLOS[tipo].titulo,
descricao: comDescricao ? EXEMPLOS[tipo].descricao : undefined,
duracao,
acao: comAcao ? { rotulo: "Desfazer", aoClicar: () => notificar.info("Ação desfeita") } : undefined,
});
return (
<div
className={
contido
? "relative min-h-96 w-full max-w-3xl overflow-hidden rounded-2xl border border-dashed border-rule"
: "w-full"
}
>
<div className="flex flex-wrap justify-center gap-2 p-5">
{(Object.keys(EXEMPLOS) as TipoDeNotificacao[]).map((tipo) => (
<button key={tipo} type="button" className={BOTAO} onClick={() => disparar(tipo)}>
{ROTULOS[tipo]}
</button>
))}
<button type="button" className={BOTAO} onClick={() => fecharNotificacao()}>
Fechar todas
</button>
</div>
<HospedeiroDeNotificacoes posicao={posicao} contido={contido} />
</div>
);
}
/** Repetir o id troca a notificação no lugar: útil para "salvando" → "salvo". */
function ExemploPeloId() {
const salvar = () => {
notificar({ id: "salvar", tipo: "info", titulo: "Salvando alterações...", duracao: 0 });
window.setTimeout(
() => notificar({ id: "salvar", tipo: "sucesso", titulo: "Alterações salvas", descricao: "Há 2 segundos." }),
1500,
);
};
return (
<div className="relative min-h-80 w-full max-w-3xl overflow-hidden rounded-2xl border border-dashed border-rule">
<div className="flex justify-center p-5">
<button type="button" className={BOTAO} onClick={salvar}>
Salvar
</button>
</div>
<HospedeiroDeNotificacoes contido posicao="inferior-centro" />
</div>
);
}
export default defineStories({
args: {
posicao: "inferior-direita",
contido: true,
duracao: 5000,
comDescricao: true,
comAcao: false,
},
controls: {
posicao: {
type: "select",
options: [
"superior-esquerda",
"superior-centro",
"superior-direita",
"inferior-esquerda",
"inferior-centro",
"inferior-direita",
],
},
contido: { type: "boolean", label: "contido (desligado: fixo na janela)" },
duracao: { type: "number", label: "duracao (ms, 0 = fixa)", min: 0, max: 15000, step: 500 },
comDescricao: { type: "boolean", label: "com descrição" },
comAcao: { type: "boolean", label: "com ação “Desfazer”" },
},
render: (a) => <Exemplo {...a} posicao={a.posicao as PosicaoDasNotificacoes} />,
stories: [
{
name: "Notificação",
render: () => (
<div className="w-full max-w-sm">
<Notificacao
tipo="sucesso"
titulo={EXEMPLOS.sucesso.titulo}
descricao={EXEMPLOS.sucesso.descricao}
aoFechar={() => {}}
/>
</div>
),
},
{
name: "Tipos",
render: () => (
<div className="flex w-full max-w-sm flex-col gap-2">
<Notificacao
tipo="sucesso"
titulo={EXEMPLOS.sucesso.titulo}
descricao={EXEMPLOS.sucesso.descricao}
aoFechar={() => {}}
/>
<Notificacao
tipo="erro"
titulo={EXEMPLOS.erro.titulo}
acao={{ rotulo: "Tentar de novo", aoClicar: () => {} }}
aoFechar={() => {}}
/>
<Notificacao tipo="aviso" titulo={EXEMPLOS.aviso.titulo} aoFechar={() => {}} />
</div>
),
},
{ name: "Disparar" },
{ name: "Fixa na janela", args: { contido: false } },
{ name: "Fixa até fechar, com ação", args: { duracao: 0, comAcao: true, posicao: "superior-direita" } },
{ name: "Atualizar pelo id", render: () => <ExemploPeloId /> },
],
});
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);
}