Etiquetas, Chips e Contadores
Etiqueta de status em pílula (tons neutro, destaque, sucesso, alerta e perigo; suave ou contorno; com ponto, ícone ou cor de categoria), Chip de filtro que liga e desliga com aria-pressed, e ContadorNumerico com teto (“9+”).
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<Etiqueta>
Selo de status ("Concluído", "Atrasado", "Rascunho"): texto curto numa pílula tingida pela cor do tom, com ponto ou ícone opcionais. Não é clicável; para um filtro que liga e desliga, use o `Chip`.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| childrenobrigatória | ReactNode | — | |
| tom | "sucesso" | "alerta" | "destaque" | "perigo" | "neutro" | "neutro" | O que a cor diz, não que cor usar: `perigo` = não siga (bloqueado, recusado); `alerta` = olhe isto (precisa de atenção, mas não bloqueia); `sucesso` = concluído, aprovado; `destaque` = informativo, em andamento. |
| variante | "contorno" | "suave" | "suave" | `suave` (fundo tingido) ou `contorno` (só a borda, para listas com muitos selos). |
| ponto | boolean | false | Ponto na cor do tom antes do texto. |
| icone | string | number | bigint | boolean | ReactElement<unknown, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<AwaitedReactNode> | — | Ícone antes do texto (SVG; sem classe de tamanho, fica com size-3.5). Substitui o ponto. |
| maiuscula | boolean | false | Caixa alta com espaçamento: liga em rótulo de estado, desliga em nome próprio. |
| cor | string | — | Cor vinda do dado (a cor de uma categoria, `var(--series-3)`), no lugar do tom. O fundo e a borda saem dela, misturados com transparência. |
| title | string | — | Texto completo ao passar o mouse (para selos cortados ou abreviados). |
| className | string | "" |
<Chip>
Pílula clicável de filtro, que liga e desliga um recorte da lista. É um `<button>` com `aria-pressed`, então entra na ordem do Tab e o leitor de tela diz se o filtro está ligado. O ligado mostra um ✓ além da cor.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| childrenobrigatória | ReactNode | — | |
| ativoobrigatória | boolean | — | Se o filtro está ligado. Componente controlado. |
| aoAlternarobrigatória | (ativo: boolean) => void | — | Recebe o novo estado (o contrário de `ativo`). |
| cor | string | — | Cor da categoria (`var(--series-2)`), num ponto antes do texto. |
| desabilitado | boolean | false | |
| title | string | — | |
| className | string | "" |
<ContadorNumerico>
Contador numérico (o "3" do sino, o "12" de uma coluna): cápsula de largura mínima, número centralizado, e "9+" acima do máximo. Zero não desenha nada: uma pastilha "0" ocupa espaço para dizer que não há o que ver.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| valorobrigatória | number | — | |
| maximo | number | 9 | Teto antes de virar "N+". |
| tom | "alerta" | "destaque" | "perigo" | "neutro" | "perigo" | |
| rotulo | string | — | Texto para leitores de tela no lugar do número ("12 notificações não lidas"). Sem ele, o número é lido sozinho; o "9+" esconde o valor real. |
| className | string | "" |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import type { CSSProperties, ReactNode } from "react";
// Duas regras por tom: fundo tingido (suave) ou só a borda (contorno). O texto vai
// sempre na cor cheia; o neutro usa a tinta a 10% porque um cinza claro sobre o
// fundo cinza de um cartão não se distingue.
const TONS = {
neutro: { suave: "border-transparent bg-ink/10 text-ink-2", contorno: "border-rule-strong text-ink-2" },
destaque: { suave: "border-transparent bg-accent/15 text-accent", contorno: "border-accent/60 text-accent" },
sucesso: { suave: "border-transparent bg-success/15 text-success", contorno: "border-success/60 text-success" },
alerta: { suave: "border-transparent bg-warning/15 text-warning", contorno: "border-warning/60 text-warning" },
perigo: { suave: "border-transparent bg-critical/15 text-critical", contorno: "border-critical/60 text-critical" },
} as const;
export type TomDeEtiqueta = keyof typeof TONS;
/**
* Selo de status ("Concluído", "Atrasado", "Rascunho"): texto curto numa pílula
* tingida pela cor do tom, com ponto ou ícone opcionais. Não é clicável; para um
* filtro que liga e desliga, use o `Chip`.
*/
export function Etiqueta({
children,
tom = "neutro",
variante = "suave",
ponto = false,
icone,
maiuscula = false,
cor,
title,
className = "",
}: {
children: ReactNode;
/**
* O que a cor diz, não que cor usar: `perigo` = não siga (bloqueado, recusado);
* `alerta` = olhe isto (precisa de atenção, mas não bloqueia); `sucesso` =
* concluído, aprovado; `destaque` = informativo, em andamento.
*/
tom?: TomDeEtiqueta;
/** `suave` (fundo tingido) ou `contorno` (só a borda, para listas com muitos selos). */
variante?: "suave" | "contorno";
/** Ponto na cor do tom antes do texto. */
ponto?: boolean;
/** Ícone antes do texto (SVG; sem classe de tamanho, fica com size-3.5). Substitui o ponto. */
icone?: ReactNode;
/** Caixa alta com espaçamento: liga em rótulo de estado, desliga em nome próprio. */
maiuscula?: boolean;
/**
* Cor vinda do dado (a cor de uma categoria, `var(--series-3)`), no lugar do tom.
* O fundo e a borda saem dela, misturados com transparência.
*/
cor?: string;
/** Texto completo ao passar o mouse (para selos cortados ou abreviados). */
title?: string;
className?: string;
}) {
const estiloDaCor: CSSProperties | undefined = cor
? {
color: cor,
backgroundColor: variante === "suave" ? `color-mix(in oklab, ${cor} 15%, transparent)` : undefined,
borderColor: variante === "contorno" ? `color-mix(in oklab, ${cor} 60%, transparent)` : "transparent",
}
: undefined;
return (
<span
title={title}
style={estiloDaCor}
className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 ${
maiuscula ? "tracking-wide uppercase" : ""
} ${cor ? "" : TONS[tom][variante]} ${className}`}
>
{icone ?? (ponto && <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-current" />)}
{children}
</span>
);
}
/**
* Pílula clicável de filtro, que liga e desliga um recorte da lista. É um
* `<button>` com `aria-pressed`, então entra na ordem do Tab e o leitor de tela
* diz se o filtro está ligado. O ligado mostra um ✓ além da cor.
*/
export function Chip({
children,
ativo,
aoAlternar,
cor,
desabilitado = false,
title,
className = "",
}: {
children: ReactNode;
/** Se o filtro está ligado. Componente controlado. */
ativo: boolean;
/** Recebe o novo estado (o contrário de `ativo`). */
aoAlternar: (ativo: boolean) => void;
/** Cor da categoria (`var(--series-2)`), num ponto antes do texto. */
cor?: string;
desabilitado?: boolean;
title?: string;
className?: string;
}) {
return (
<button
type="button"
aria-pressed={ativo}
onClick={() => aoAlternar(!ativo)}
disabled={desabilitado}
title={title}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1 text-sm whitespace-nowrap transition-colors outline-accent focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
ativo ? "border-accent bg-accent/10 text-ink" : "border-rule bg-surface text-ink-2 hover:border-rule-strong hover:text-ink"
} ${className}`}
>
{ativo && (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="-ml-0.5 size-3.5 shrink-0 text-accent"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m5 12.5 4.5 4.5L19 7.5" />
</svg>
)}
{cor && <span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: cor }} />}
{children}
</button>
);
}
const TONS_DO_CONTADOR = {
destaque: "bg-accent text-page",
alerta: "bg-warning text-page",
perigo: "bg-critical text-page",
neutro: "bg-ink/10 text-ink-2",
} as const;
/**
* Contador numérico (o "3" do sino, o "12" de uma coluna): cápsula de largura
* mínima, número centralizado, e "9+" acima do máximo. Zero não desenha nada:
* uma pastilha "0" ocupa espaço para dizer que não há o que ver.
*/
export function ContadorNumerico({
valor,
maximo = 9,
tom = "perigo",
rotulo,
className = "",
}: {
valor: number;
/** Teto antes de virar "N+". */
maximo?: number;
tom?: keyof typeof TONS_DO_CONTADOR;
/**
* Texto para leitores de tela no lugar do número ("12 notificações não lidas").
* Sem ele, o número é lido sozinho; o "9+" esconde o valor real.
*/
rotulo?: string;
className?: string;
}) {
if (!valor || valor < 1) return null;
const texto = valor > maximo ? `${maximo}+` : String(valor);
return (
<span
className={`inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs leading-none font-semibold tabular-nums ${TONS_DO_CONTADOR[tom]} ${className}`}
>
{rotulo ? (
<>
<span aria-hidden="true">{texto}</span>
<span className="sr-only">{rotulo}</span>
</>
) : (
texto
)}
</span>
);
}
import { useState } from "react";
import { defineStories } from "@/registry/stories";
import { Chip, ContadorNumerico, Etiqueta, type TomDeEtiqueta } from "./Etiquetas";
const STATUS: { tom: TomDeEtiqueta; texto: string }[] = [
{ tom: "neutro", texto: "Rascunho" },
{ tom: "destaque", texto: "Em andamento" },
{ tom: "sucesso", texto: "Concluído" },
{ tom: "alerta", texto: "Revisar" },
{ tom: "perigo", texto: "Atrasado" },
];
const CATEGORIAS = ["Vendas", "Marketing", "Financeiro", "Suporte", "Produto"];
function ChipDeExemplo({ texto }: { texto: string }) {
const [ativo, setAtivo] = useState(true);
return (
<Chip ativo={ativo} aoAlternar={setAtivo} cor="var(--series-2)">
{texto}
</Chip>
);
}
function Relogio() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
}
function Cadeado() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg>
);
}
function Filtros() {
const [ativos, setAtivos] = useState<string[]>(["Vendas", "Suporte"]);
return (
<div className="flex max-w-xl flex-col gap-3">
<p className="text-sm text-ink-2">Equipes</p>
<div className="flex flex-wrap gap-2">
{CATEGORIAS.map((c, i) => (
<Chip
key={c}
ativo={ativos.includes(c)}
aoAlternar={(on) => setAtivos((atual) => (on ? [...atual, c] : atual.filter((x) => x !== c)))}
cor={`var(--series-${i + 1})`}
>
{c}
</Chip>
))}
</div>
</div>
);
}
export default defineStories({
args: {
mostrar: "etiqueta",
texto: "Em andamento",
tom: "destaque",
variante: "suave",
ponto: true,
maiuscula: false,
contador: 12,
maximo: 9,
},
controls: {
mostrar: { type: "select", label: "componente", options: ["etiqueta", "chip", "contador"] },
texto: { type: "text", label: "children (Etiqueta e Chip)" },
tom: {
type: "select",
label: "tom (Etiqueta)",
options: ["neutro", "destaque", "sucesso", "alerta", "perigo"],
},
variante: { type: "select", label: "variante (Etiqueta)", options: ["suave", "contorno"] },
ponto: { type: "boolean", label: "ponto (Etiqueta)" },
maiuscula: { type: "boolean", label: "maiuscula (Etiqueta)" },
contador: { type: "number", label: "valor (ContadorNumerico)", min: 0, max: 200 },
maximo: { type: "number", label: "maximo (ContadorNumerico)", min: 1, max: 999 },
},
// Um componente por vez: Etiqueta, Chip ou ContadorNumerico
render: (a) =>
a.mostrar === "chip" ? (
<ChipDeExemplo texto={a.texto} />
) : a.mostrar === "contador" ? (
<ContadorNumerico
valor={a.contador}
maximo={a.maximo}
rotulo={`${a.contador} notificações não lidas`}
/>
) : (
<Etiqueta
tom={a.tom as TomDeEtiqueta}
variante={a.variante as "suave" | "contorno"}
ponto={a.ponto}
maiuscula={a.maiuscula}
>
{a.texto}
</Etiqueta>
),
stories: [
{ name: "Etiqueta" },
{ name: "Chip", args: { mostrar: "chip", texto: "Vendas" } },
{ name: "Contador numérico", args: { mostrar: "contador" } },
{
name: "Status",
render: () => (
<div className="flex max-w-xl flex-col gap-3">
<div className="flex flex-wrap gap-2">
{STATUS.map((s) => (
<Etiqueta key={s.tom} tom={s.tom} ponto>
{s.texto}
</Etiqueta>
))}
</div>
<div className="flex flex-wrap gap-2">
{STATUS.map((s) => (
<Etiqueta key={s.tom} tom={s.tom} variante="contorno" maiuscula>
{s.texto}
</Etiqueta>
))}
</div>
</div>
),
},
{
name: "Com ícone e cor de categoria",
render: () => (
<div className="flex max-w-xl flex-wrap gap-2">
<Etiqueta tom="alerta" icone={<Relogio />}>
Vence amanhã
</Etiqueta>
<Etiqueta tom="perigo" variante="contorno" icone={<Cadeado />}>
Bloqueado
</Etiqueta>
{CATEGORIAS.slice(0, 3).map((c, i) => (
<Etiqueta key={c} cor={`var(--series-${i + 1})`} ponto>
{c}
</Etiqueta>
))}
</div>
),
},
{ name: "Chips de filtro", render: () => <Filtros /> },
{
name: "Contadores",
render: () => (
<div className="flex flex-wrap items-center gap-6 text-sm text-ink-2">
<span className="inline-flex items-center gap-2">
Notificações <ContadorNumerico valor={3} rotulo="3 não lidas" />
</span>
<span className="inline-flex items-center gap-2">
Mensagens <ContadorNumerico valor={27} rotulo="27 não lidas" />
</span>
<span className="inline-flex items-center gap-2">
A fazer <ContadorNumerico valor={12} maximo={99} tom="neutro" />
</span>
<span className="inline-flex items-center gap-2">
Novidades <ContadorNumerico valor={5} tom="destaque" />
</span>
<span className="inline-flex items-center gap-2">
Zero não aparece: <ContadorNumerico valor={0} />
</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);
}