Indicador analógico de tablero
Arco con zonas verde, ámbar y rojo, aguja con resorte y pantalla LCD; skeuomórfico oscuro con valor ajustable.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useState, type CSSProperties } from "react";
/** Indicador analógico de tablero skeuomórfico: arco con zonas de color, aguja que oscila con resorte y pantalla digital. Valor controlable con teclado. React + TypeScript, sin dependencias. */
export type DatosGaugeProps = {
value?: number;
defaultValue?: number;
min?: number;
max?: number;
label?: string;
unit?: string;
onChange?: (value: number) => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcdga-root { box-sizing: border-box; display: inline-grid; justify-items: center; gap: 8px; width: 280px; max-width: 100%; padding: 22px 18px 18px; border-radius: 24px; background: linear-gradient(180deg, #2c2e34, #1b1c20); color: #dfe2ea; font: 600 12px/1 "Helvetica Neue", Arial, ui-sans-serif, sans-serif; letter-spacing: .14em; text-transform: uppercase; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .14), inset 0 -2px 6px rgba(0, 0, 0, .5), 0 18px 28px -12px rgba(0, 0, 0, .7); }
.gcdga-root *, .gcdga-root *::before, .gcdga-root *::after { box-sizing: border-box; }
.gcdga-root button { font: inherit; letter-spacing: inherit; text-transform: inherit; }
.gcdga-root :focus-visible { outline: 2px solid #5aa9ff; outline-offset: 3px; border-radius: 8px; }
@media (prefers-reduced-motion: reduce) { .gcdga-root * { transition-duration: .01ms !important; } }
.gcdga-dial { position: relative; width: 240px; height: 140px; max-width: 100%; }
.gcdga-dial svg { width: 100%; height: 100%; overflow: visible; }
.gcdga-needle { transform-origin: 120px 120px; transition: transform .9s cubic-bezier(.34, 1.4, .5, 1); }
.gcdga-lcd { min-width: 110px; padding: 8px 12px; border-radius: 6px; background: #0b140d; color: #7dffa1; text-align: center; font: 600 22px/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: .08em; text-shadow: 0 0 8px rgba(125, 255, 161, .6); box-shadow: inset 0 2px 5px rgba(0, 0, 0, .9), 0 1px 0 rgba(255, 255, 255, .15); font-variant-numeric: tabular-nums; }
.gcdga-adj { display: flex; gap: 8px; }
.gcdga-adj button { width: 40px; height: 32px; border: 0; border-radius: 6px; background: linear-gradient(180deg, #4a4d55, #34363c); color: #fff; font-size: 18px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25), 0 2px 0 #121316; }
.gcdga-adj button:active { transform: translateY(1px); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .25); }`;
const polar = (cx: number, cy: number, r: number, deg: number) => {
const a = ((deg - 180) * Math.PI) / 180;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)] as const;
};
function arc(r: number, from: number, to: number) {
const [x1, y1] = polar(120, 120, r, from);
const [x2, y2] = polar(120, 120, r, to);
return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${to - from > 180 ? 1 : 0} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`;
}
export function DatosGauge({ value, defaultValue = 68, min = 0, max = 100, label = "Carga del sistema", unit = "%", onChange, className = "", style }: DatosGaugeProps) {
const [inner, setInner] = useState(defaultValue);
const v = Math.max(min, Math.min(max, value ?? inner));
const t = (v - min) / (max - min);
const set = (next: number) => {
const clamped = Math.max(min, Math.min(max, next));
setInner(clamped);
onChange?.(clamped);
};
const zones = [
{ from: 0, to: 0.6, c: "#38c172" },
{ from: 0.6, to: 0.85, c: "#f6c343" },
{ from: 0.85, to: 1, c: "#ef5350" },
];
return (
<div className={`gcdga-root ${className}`} style={style} role="meter" aria-label={label} aria-valuemin={min} aria-valuemax={max} aria-valuenow={Math.round(v)} aria-valuetext={`${Math.round(v)}${unit}`}>
<style>{CSS}</style>
<div className="gcdga-dial">
<svg viewBox="0 0 240 140" aria-hidden="true">
<path d={arc(100, 0, 180)} fill="none" stroke="#0e0f12" strokeWidth="20" strokeLinecap="round" />
{zones.map((zone) => (
<path key={zone.c} d={arc(100, zone.from * 180 + (zone.from === 0 ? 0 : 1.5), zone.to * 180 - (zone.to === 1 ? 0 : 1.5))} fill="none" stroke={zone.c} strokeWidth="14" strokeLinecap="butt" opacity=".92" />
))}
{Array.from({ length: 21 }, (_, i) => {
const deg = (i / 20) * 180;
const [x1, y1] = polar(120, 120, 82, deg);
const [x2, y2] = polar(120, 120, i % 5 === 0 ? 70 : 76, deg);
return <line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#8d92a0" strokeWidth={i % 5 === 0 ? 2 : 1} />;
})}
<g className="gcdga-needle" style={{ transform: `rotate(${t * 180 - 90}deg)` }}>
<path d="M120 120 L116 118 L120 36 L124 118 Z" fill="#ff5b4a" />
<path d="M120 120 L120 36" stroke="rgba(255,255,255,.4)" strokeWidth="1" />
</g>
<circle cx="120" cy="120" r="12" fill="#cfd2d9" stroke="#0e0f12" strokeWidth="3" />
<circle cx="120" cy="120" r="4" fill="#4a4d55" />
</svg>
</div>
<span className="gcdga-lcd" aria-hidden="true">
{Math.round(v)}
{unit}
</span>
<span aria-hidden="true">{label}</span>
<div className="gcdga-adj">
<button type="button" aria-label="Reducir" onClick={() => set(v - 10)}>
−
</button>
<button type="button" aria-label="Aumentar" onClick={() => set(v + 10)}>
+
</button>
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/datos-gaugeEjemplo de uso de Indicador analógico de tablero
Después de copiar datos-gauge.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { DatosGauge } from "./datos-gauge";
export default function Ejemplo() {
return <DatosGauge label="Ejemplo" />;
}Propiedades de DatosGauge
DatosGauge acepta 9 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
value? | number | Opcional. |
defaultValue? | number | Opcional. |
min? | number | Opcional. |
max? | number | Opcional. |
label? | string | Opcional. |
unit? | string | Opcional. |
onChange? | (value: number) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 95 líneas (5 KB) y no necesita @keyframes. Esto es lo que hace Indicador analógico de tablero por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: meter.
- Es un componente de cliente de Next.js: incluye la directiva "use client".