Barra de Progresso
Barra de progresso determinada (valor e máximo, com rótulo e valor formatado, em quatro tons) ou indeterminada, com uma faixa que corre pelo trilho para esperas que não dá para medir. role=progressbar com os aria-value* do caso.
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<BarraDeProgresso>
Barra de progresso. Com `valor`, é determinada (envio de arquivo, itens concluídos de uma lista); sem `valor`, é indeterminada: uma faixa que corre pelo trilho, para esperas que não dá para medir. Usa `role="progressbar"` com os `aria-value*` do caso.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| valor | number | — | Quanto já foi feito, de 0 a `maximo`. Omita para a barra indeterminada. |
| maximo | number | 100 | O total (100 para porcentagem, ou o número de itens). |
| rotuloobrigatória | string | — | O que está em andamento ("Enviando arquivo..."). Aparece acima da barra e dá nome a ela. |
| ocultarRotulo | boolean | false | Esconde o rótulo da tela (continua dando nome à barra para leitores de tela). |
| mostrarValor | boolean | true | Mostra o valor formatado à direita do rótulo (só na determinada). |
| formatarValor | (valor: number, maximo: number) => string | porcentagem | Formata o valor mostrado e lido pelo leitor de tela. Padrão: porcentagem ("60%"); ex.: (v, m) => `${v} de ${m}`. |
| tom | "sucesso" | "alerta" | "destaque" | "perigo" | "destaque" | |
| tamanho | "sm" | "md" | "sm" | Espessura do trilho. |
| className | string | "" |
Código
stories.tsx tem os exemplos usados no preview e no playground.
/* Barra indeterminada: para trabalho cuja duração não dá para medir. Uma barra de
0 a 100% ali seria mentira; esta faixa só diz "ainda estou trabalhando".
A cor vem de `currentColor` (a classe de texto do tom no trilho). */
.indeterminada {
position: relative;
overflow: hidden;
}
.indeterminada::after {
content: "";
position: absolute;
inset: 0;
width: 40%;
border-radius: inherit;
background: linear-gradient(to right, transparent, currentColor, transparent);
animation: indeterminado 1.4s ease-in-out infinite;
}
@keyframes indeterminado {
from {
transform: translateX(-100%);
}
to {
transform: translateX(350%);
}
}
/* Sem movimento a faixa para, mas continua visível: quem pediu menos movimento
não pediu para perder o aviso de que algo está em andamento. A classe vem de
useSemMovimento(); a media query cobre o primeiro quadro, antes da hidratação. */
.parada::after {
animation: none;
width: 100%;
opacity: 0.5;
}
@media (prefers-reduced-motion: reduce) {
.indeterminada::after {
animation: none;
width: 100%;
opacity: 0.5;
}
}
"use client";
import { useId } from "react";
import { useSemMovimento } from "@/library/revelar/Movimento";
import estilos from "./BarraDeProgresso.module.css";
// A classe de texto pinta a faixa indeterminada (currentColor); `bg-current` pinta o preenchimento.
const TONS = {
destaque: "text-accent",
sucesso: "text-success",
alerta: "text-warning",
perigo: "text-critical",
} as const;
const ESPESSURAS = { sm: "h-1.5", md: "h-2.5" } as const;
const porcentagem = (valor: number, maximo: number) => `${Math.round((valor / maximo) * 100)}%`;
/**
* Barra de progresso. Com `valor`, é determinada (envio de arquivo, itens
* concluídos de uma lista); sem `valor`, é indeterminada: uma faixa que corre
* pelo trilho, para esperas que não dá para medir. Usa `role="progressbar"` com
* os `aria-value*` do caso.
*/
export function BarraDeProgresso({
valor,
maximo = 100,
rotulo,
ocultarRotulo = false,
mostrarValor = true,
formatarValor = porcentagem,
tom = "destaque",
tamanho = "sm",
className = "",
}: {
/** Quanto já foi feito, de 0 a `maximo`. Omita para a barra indeterminada. */
valor?: number;
/** O total (100 para porcentagem, ou o número de itens). */
maximo?: number;
/** O que está em andamento ("Enviando arquivo..."). Aparece acima da barra e dá nome a ela. */
rotulo: string;
/** Esconde o rótulo da tela (continua dando nome à barra para leitores de tela). */
ocultarRotulo?: boolean;
/** Mostra o valor formatado à direita do rótulo (só na determinada). */
mostrarValor?: boolean;
/** Formata o valor mostrado e lido pelo leitor de tela. Padrão: porcentagem ("60%"); ex.: (v, m) => `${v} de ${m}`. */
formatarValor?: (valor: number, maximo: number) => string;
tom?: keyof typeof TONS;
/** Espessura do trilho. */
tamanho?: keyof typeof ESPESSURAS;
className?: string;
}) {
const semMovimento = useSemMovimento();
const idRotulo = useId();
const determinada = valor != null;
const max = maximo > 0 ? maximo : 1;
const atual = determinada ? Math.min(Math.max(valor, 0), max) : 0;
const texto = determinada ? formatarValor(atual, max) : undefined;
const valorVisivel = determinada && mostrarValor;
const elementoDoRotulo = (
<span id={idRotulo} className={ocultarRotulo ? "sr-only" : ""}>
{rotulo}
</span>
);
return (
<div className={`flex flex-col gap-1.5 ${className}`}>
{!ocultarRotulo || valorVisivel ? (
<div className="flex items-baseline justify-between gap-3 text-xs text-ink-2">
{elementoDoRotulo}
{valorVisivel && (
// O leitor de tela já ouve o valor pelo aria-valuetext da barra.
<span aria-hidden="true" className="ml-auto tabular-nums">
{texto}
</span>
)}
</div>
) : (
elementoDoRotulo
)}
<div
role="progressbar"
aria-labelledby={idRotulo}
aria-valuemin={determinada ? 0 : undefined}
aria-valuemax={determinada ? max : undefined}
aria-valuenow={determinada ? atual : undefined}
aria-valuetext={texto}
className={`w-full overflow-hidden rounded-full bg-track ${ESPESSURAS[tamanho]} ${TONS[tom]} ${
determinada ? "" : `${estilos.indeterminada} ${semMovimento ? estilos.parada : ""}`
}`}
>
{determinada && (
<div
className={`h-full rounded-full bg-current ${semMovimento ? "" : "transition-[width] duration-300 ease-out"}`}
style={{ width: `${(atual / max) * 100}%` }}
/>
)}
</div>
</div>
);
}
import { useEffect, useState } from "react";
import { defineStories } from "@/registry/stories";
import { BarraDeProgresso } from "./BarraDeProgresso";
type Tom = "destaque" | "sucesso" | "alerta" | "perigo";
const deItens = (v: number, m: number) => `${v} de ${m} (${Math.round((v / m) * 100)}%)`;
function EnvioSimulado() {
const [valor, setValor] = useState(0);
useEffect(() => {
const t = window.setInterval(() => setValor((v) => (v >= 100 ? 0 : Math.min(100, v + 7))), 400);
return () => window.clearInterval(t);
}, []);
return (
<div className="w-full max-w-md">
<BarraDeProgresso
valor={valor}
rotulo={valor >= 100 ? "Concluindo..." : "Enviando planilha.xlsx..."}
tamanho="md"
/>
</div>
);
}
export default defineStories({
args: {
rotulo: "Enviando contrato.pdf...",
valor: 45,
maximo: 100,
indeterminada: false,
tom: "destaque",
tamanho: "sm",
mostrarValor: true,
ocultarRotulo: false,
},
controls: {
rotulo: { type: "text" },
valor: { type: "number", min: 0, max: 100 },
maximo: { type: "number", min: 1, max: 100 },
indeterminada: { type: "boolean", label: "indeterminada (sem valor)" },
tom: { type: "select", options: ["destaque", "sucesso", "alerta", "perigo"] },
tamanho: { type: "select", options: ["sm", "md"] },
mostrarValor: { type: "boolean" },
ocultarRotulo: { type: "boolean" },
},
render: (a) => (
<div className="w-full max-w-md">
<BarraDeProgresso
rotulo={a.rotulo}
valor={a.indeterminada ? undefined : a.valor}
maximo={a.maximo}
tom={a.tom as Tom}
tamanho={a.tamanho as "sm" | "md"}
mostrarValor={a.mostrarValor}
ocultarRotulo={a.ocultarRotulo}
/>
</div>
),
stories: [
{ name: "Padrão" },
{
name: "Determinada e indeterminada",
render: () => (
<div className="flex w-full max-w-md flex-col gap-6">
<BarraDeProgresso valor={45} rotulo="Enviando contrato.pdf..." />
<BarraDeProgresso
valor={3}
maximo={5}
rotulo="Checklist"
formatarValor={deItens}
tom="sucesso"
tamanho="md"
/>
<BarraDeProgresso rotulo="Analisando o documento..." />
</div>
),
},
{ name: "Indeterminada", args: { indeterminada: true, rotulo: "Gerando relatório..." } },
{
name: "Tons",
render: () => (
<div className="flex w-full max-w-md flex-col gap-5">
<BarraDeProgresso valor={72} rotulo="Meta de vendas" tom="destaque" />
<BarraDeProgresso valor={100} rotulo="Backup" tom="sucesso" />
<BarraDeProgresso valor={86} rotulo="Armazenamento usado" tom="alerta" />
<BarraDeProgresso valor={97} rotulo="Cota de envios" tom="perigo" />
</div>
),
},
{ name: "Envio simulado", render: () => <EnvioSimulado /> },
],
});
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);
}