Medidor Sólido
Medidor de meia-volta em SVG para um número em relação a um limite: arco sólido que cresce ao entrar na tela, número no centro, limites nas pontas e cor por faixas (bom, atenção, crítico). Exposto como role="meter".
Playground
Props
Geradas dos tipos TypeScript do componente; a descrição vem dos comentários /** */.
<MedidorSolido>
Medidor de meia-volta para um único número em relação a um limite: arco sólido sobre um trilho, número grande no centro e limites nas pontas. O arco cresce ao entrar na tela (ou aparece direto com "reduzir movimento") e pode mudar de cor por faixas. Exposto como `role="meter"` para leitores de tela.
| Prop | Tipo | Padrão | Descrição |
|---|---|---|---|
| valorobrigatória | number | — | |
| min | number | 0 | |
| max | number | 100 | |
| rotuloobrigatória | string | — | Nome do indicador: vira o título acessível e aparece abaixo do número. |
| formatar | (valor: number) => string | (n) => n.toLocaleString("pt-BR") | Formata o número do centro e os limites ("69%", "R$ 69 mil"). |
| cor | string | "var(--series-1)" | Cor do arco. Com `faixas`, a cor vem da faixa em que o valor cai. |
| faixas | FaixaDoMedidor[] | — | Muda a cor do arco conforme o valor (ex.: até 60 bom, até 85 atenção, acima crítico). |
| espessura | number | 0.32 | Grossura do arco em relação ao raio, de 0,1 a 0,5. |
| mostrarLimites | boolean | true | Mostra o mínimo e o máximo nas pontas do arco. |
Código
stories.tsx tem os exemplos usados no preview e no playground.
"use client";
import { useRef } from "react";
import { CURVA_SUAVE, useEmTela, useSemMovimento } from "@/library/revelar/Movimento";
/** Faixa de cor: vale do fim da faixa anterior até `ate` (a última faixa pode omitir `ate`). */
export type FaixaDoMedidor = { ate?: number; cor: string; rotulo?: string };
export type MedidorSolidoProps = {
valor: number;
min?: number;
max?: number;
/** Nome do indicador: vira o título acessível e aparece abaixo do número. */
rotulo: string;
/** Formata o número do centro e os limites ("69%", "R$ 69 mil"). */
formatar?: (valor: number) => string;
/** Cor do arco. Com `faixas`, a cor vem da faixa em que o valor cai. */
cor?: string;
/** Muda a cor do arco conforme o valor (ex.: até 60 bom, até 85 atenção, acima crítico). */
faixas?: FaixaDoMedidor[];
/** Grossura do arco em relação ao raio, de 0,1 a 0,5. */
espessura?: number;
/** Mostra o mínimo e o máximo nas pontas do arco. */
mostrarLimites?: boolean;
};
const limitar = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n));
/** Ponto do arco: 0 é a ponta esquerda, 1 a direita, passando pelo topo. */
function ponto(f: number, r: number, cx: number, cy: number) {
const angulo = Math.PI * (1 - f);
// Duas casas: servidor e navegador podem diferir na última casa do seno (erro de hidratação)
return [Math.round((cx + r * Math.cos(angulo)) * 100) / 100, Math.round((cy - r * Math.sin(angulo)) * 100) / 100] as const;
}
/** Faixa de arco entre as frações `a` e `b`, entre os raios interno e externo. */
function setor(a: number, b: number, rFora: number, rDentro: number, cx: number, cy: number) {
const [x1, y1] = ponto(a, rFora, cx, cy);
const [x2, y2] = ponto(b, rFora, cx, cy);
const [x3, y3] = ponto(b, rDentro, cx, cy);
const [x4, y4] = ponto(a, rDentro, cx, cy);
return `M${x1},${y1} A${rFora},${rFora} 0 0 1 ${x2},${y2} L${x3},${y3} A${rDentro},${rDentro} 0 0 0 ${x4},${y4} Z`;
}
/**
* Medidor de meia-volta para um único número em relação a um limite: arco sólido sobre um
* trilho, número grande no centro e limites nas pontas. O arco cresce ao entrar na tela (ou
* aparece direto com "reduzir movimento") e pode mudar de cor por faixas. Exposto como
* `role="meter"` para leitores de tela.
*/
export function MedidorSolido({
valor,
min = 0,
max = 100,
rotulo,
formatar = (n) => n.toLocaleString("pt-BR"),
cor = "var(--series-1)",
faixas,
espessura = 0.32,
mostrarLimites = true,
}: MedidorSolidoProps) {
const ref = useRef<SVGSVGElement>(null);
const semMovimento = useSemMovimento();
const visivel = useEmTela(ref);
const mostrar = semMovimento || visivel;
const f = max === min ? 0 : limitar((valor - min) / (max - min), 0, 1);
const corAtual = faixas?.find((fx) => fx.ate === undefined || valor <= fx.ate)?.cor ?? cor;
const faixaAtual = faixas?.find((fx) => fx.ate === undefined || valor <= fx.ate);
// Coordenadas num quadro de 200 x 118: o arco ocupa a metade de cima de um círculo de raio 90
const cx = 100;
const cy = 100;
const rFora = 90;
const rDentro = rFora * (1 - limitar(espessura, 0.1, 0.5));
const meio = (rFora + rDentro) / 2;
const grossura = rFora - rDentro;
// O arco de valor é um traço grosso sobre a linha central; o comprimento anima com dasharray
const comprimento = Math.round(Math.PI * meio * 100) / 100;
const [ix, iy] = ponto(0, meio, cx, cy);
const [fx, fy] = ponto(1, meio, cx, cy);
return (
<figure className="m-0 flex w-full max-w-xs flex-col items-center">
<svg
ref={ref}
viewBox="0 0 200 118"
className="w-full overflow-visible"
role="meter"
aria-label={rotulo}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={valor}
aria-valuetext={`${formatar(valor)}${faixaAtual?.rotulo ? `, ${faixaAtual.rotulo}` : ""}`}
>
<path d={setor(0, 1, rFora, rDentro, cx, cy)} fill="var(--grid)" />
{/* Ponta reta no início (encosta na base) e arredondada no fim, como as barras */}
<path
d={`M${ix},${iy} A${meio},${meio} 0 0 1 ${fx},${fy}`}
fill="none"
stroke={corAtual}
strokeWidth={grossura}
strokeDasharray={`${comprimento} ${comprimento}`}
strokeDashoffset={mostrar ? comprimento * (1 - f) : comprimento}
style={semMovimento ? undefined : { transition: `stroke-dashoffset 900ms ${CURVA_SUAVE}, stroke 300ms` }}
/>
<text x={cx} y={cy - 8} textAnchor="middle" className="fill-ink font-display font-bold tabular-nums" style={{ fontSize: 30 }}>
{formatar(valor)}
</text>
{mostrarLimites && (
<>
<text x={cx - meio} y={cy + 15} textAnchor="middle" className="fill-ink-2 font-semibold tabular-nums" style={{ fontSize: 10 }}>
{formatar(min)}
</text>
<text x={cx + meio} y={cy + 15} textAnchor="middle" className="fill-ink-2 font-semibold tabular-nums" style={{ fontSize: 10 }}>
{formatar(max)}
</text>
</>
)}
</svg>
<figcaption className="-mt-1 text-center text-sm text-ink-2">
{rotulo}
{faixaAtual?.rotulo && (
<span className="ml-2 inline-flex items-center gap-1.5 font-medium text-ink">
<span aria-hidden="true" className="inline-block size-2 rounded-full" style={{ background: corAtual }} />
{faixaAtual.rotulo}
</span>
)}
</figcaption>
</figure>
);
}
import { defineStories } from "@/registry/stories";
import { MedidorSolido } from "./MedidorSolido";
const SEMAFORO = [
{ ate: 60, cor: "var(--success)", rotulo: "Saudável" },
{ ate: 85, cor: "var(--warning)", rotulo: "Atenção" },
{ cor: "var(--critical)", rotulo: "Crítico" },
];
export default defineStories({
args: { valor: 69, min: 0, max: 200, rotulo: "Pedidos hoje", espessura: 0.32, mostrarLimites: true, comFaixas: false },
controls: {
valor: { type: "number" },
min: { type: "number" },
max: { type: "number" },
rotulo: { type: "text" },
espessura: { type: "number", min: 0.1, max: 0.5, step: 0.02 },
mostrarLimites: { type: "boolean" },
comFaixas: { type: "boolean", label: "faixas (semáforo, em % do máximo)" },
},
render: (a) => (
<div className="flex w-full justify-center">
<MedidorSolido
valor={a.valor as number}
min={a.min as number}
max={a.max as number}
rotulo={a.rotulo as string}
espessura={a.espessura as number}
mostrarLimites={a.mostrarLimites as boolean}
faixas={
a.comFaixas
? SEMAFORO.map((f) => ({ ...f, ate: f.ate === undefined ? undefined : ((a.max as number) * f.ate) / 100 }))
: undefined
}
/>
</div>
),
stories: [
{ name: "Medidor" },
{
name: "Faixas de status",
render: () => (
<div className="grid w-full max-w-3xl grid-cols-1 gap-8 sm:grid-cols-3">
{[42, 78, 93].map((v) => (
<MedidorSolido key={v} valor={v} rotulo="Uso de CPU" formatar={(n) => `${n}%`} faixas={SEMAFORO} />
))}
</div>
),
},
{
name: "Espessuras e formatos",
render: () => (
<div className="grid w-full max-w-3xl grid-cols-1 gap-8 sm:grid-cols-3">
<MedidorSolido valor={0.72} max={1} rotulo="Meta do trimestre" espessura={0.14} formatar={(n) => `${Math.round(n * 100)}%`} cor="var(--series-2)" />
<MedidorSolido
valor={48500}
max={80000}
rotulo="Receita do mês"
formatar={(n) => n.toLocaleString("pt-BR", { style: "currency", currency: "BRL", notation: "compact" })}
cor="var(--series-3)"
/>
<MedidorSolido valor={4.6} max={5} rotulo="Satisfação" espessura={0.5} formatar={(n) => n.toLocaleString("pt-BR")} cor="var(--series-4)" />
</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.
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);
}