Gráfico de línea con mira
Línea con área, punto activo y tooltip; se explora con puntero o con flechas del teclado. Oscuro y sobrio.
Oct84k
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
/** Gráfico de línea con mira, punto activo y tooltip: se explora con puntero o con flechas del teclado. Eje con etiquetas HTML reales y área de relleno. Oscuro y sobrio. Datos de muestra. React + TypeScript, sin dependencias. */
export type DatosLineaPunto = { label: string; value: number };
export type DatosLineaProps = {
data?: DatosLineaPunto[];
title?: string;
format?: (value: number) => string;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcdln-root { box-sizing: border-box; width: 100%; max-width: 640px; padding: 20px 20px 14px; border: 1px solid #23262f; border-radius: 16px; background: #0f1115; color: #e6e8ee; font: 500 13px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcdln-root *, .gcdln-root *::before, .gcdln-root *::after { box-sizing: border-box; }
.gcdln-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.gcdln-title { margin: 0; font-size: 14px; font-weight: 600; }
.gcdln-big { font: 700 26px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.gcdln-plot { position: relative; height: 200px; outline: 0; touch-action: pan-y; }
.gcdln-plot:focus-visible { outline: 2px solid #84b3ff; outline-offset: 4px; border-radius: 6px; }
.gcdln-plot svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.gcdln-grid line { stroke: #23262f; stroke-width: 1; vector-effect: non-scaling-stroke; }
.gcdln-line { fill: none; stroke: #84b3ff; stroke-width: 2.2; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; stroke-dasharray: 1; stroke-dashoffset: 1; animation: gcdln-draw 1.2s cubic-bezier(.16, 1, .3, 1) forwards; }
.gcdln-cross { stroke: #3a3f4c; stroke-width: 1; stroke-dasharray: 3 3; vector-effect: non-scaling-stroke; }
.gcdln-dot { position: absolute; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; border: 3px solid #84b3ff; background: #0f1115; pointer-events: none; transition: left .12s ease-out, top .12s ease-out; }
.gcdln-tip { position: absolute; z-index: 2; min-width: 108px; padding: 8px 10px; border: 1px solid #2c303b; border-radius: 8px; background: #171a21; pointer-events: none; transform: translate(-50%, calc(-100% - 14px)); transition: left .12s ease-out, top .12s ease-out; white-space: nowrap; }
.gcdln-tip[data-below="true"] { transform: translate(-50%, 16px); }
.gcdln-tip small { display: block; color: #8b92a5; font-size: 11px; }
.gcdln-tip b { font-variant-numeric: tabular-nums; }
.gcdln-axis { display: flex; justify-content: space-between; margin-top: 10px; color: #7b8294; font-size: 11px; }
@keyframes gcdln-draw { to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) { .gcdln-line { animation: none; stroke-dashoffset: 0; } .gcdln-dot, .gcdln-tip { transition: none; } }`;
const DEFAULT: DatosLineaPunto[] = [
{ label: "Ene", value: 32 }, { label: "Feb", value: 41 }, { label: "Mar", value: 38 }, { label: "Abr", value: 54 }, { label: "May", value: 49 }, { label: "Jun", value: 63 },
{ label: "Jul", value: 58 }, { label: "Ago", value: 72 }, { label: "Sep", value: 69 }, { label: "Oct", value: 84 },
];
export function DatosLinea({ data = DEFAULT, title = "Usuarios activos", format = (value) => `${value}k`, className = "", style }: DatosLineaProps) {
const id = useId();
const [i, setI] = useState(data.length - 1);
const plot = useRef<HTMLDivElement>(null);
const min = Math.min(...data.map((p) => p.value)) * 0.8;
const max = Math.max(...data.map((p) => p.value)) * 1.05;
const px = (n: number) => (n / (data.length - 1)) * 100;
const py = (v: number) => 100 - ((v - min) / (max - min)) * 100;
const path = data.map((p, n) => `${n ? "L" : "M"} ${px(n)} ${py(p.value)}`).join(" ");
function move(event: PointerEvent) {
const rect = plot.current!.getBoundingClientRect();
const ratio = (event.clientX - rect.left) / rect.width;
setI(Math.max(0, Math.min(data.length - 1, Math.round(ratio * (data.length - 1)))));
}
function key(event: KeyboardEvent) {
if (event.key === "ArrowRight") { event.preventDefault(); setI((v) => Math.min(data.length - 1, v + 1)); }
if (event.key === "ArrowLeft") { event.preventDefault(); setI((v) => Math.max(0, v - 1)); }
if (event.key === "Home") { event.preventDefault(); setI(0); }
if (event.key === "End") { event.preventDefault(); setI(data.length - 1); }
}
return (
<figure className={`gcdln-root ${className}`} style={{ margin: 0, ...style }}>
<style>{CSS}</style>
<div className="gcdln-head">
<figcaption className="gcdln-title">{title}</figcaption>
<span className="gcdln-big" aria-live="polite">{format(data[i].value)}</span>
</div>
<div ref={plot} className="gcdln-plot" tabIndex={0} role="img" aria-label={`${title}. ${data[i].label}: ${format(data[i].value)}. Usa las flechas para explorar.`} onPointerMove={move} onPointerDown={move} onKeyDown={key}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id={`${id}-a`} x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#84b3ff" stopOpacity=".28" /><stop offset="1" stopColor="#84b3ff" stopOpacity="0" /></linearGradient>
</defs>
<g className="gcdln-grid">
{[0, 25, 50, 75, 100].map((y) => (
<line key={y} x1="0" x2="100" y1={y} y2={y} />
))}
</g>
<path d={`${path} L 100 100 L 0 100 Z`} fill={`url(#${id}-a)`} />
<path className="gcdln-line" d={path} pathLength={1} />
<line className="gcdln-cross" x1={px(i)} x2={px(i)} y1="0" y2="100" />
</svg>
<span className="gcdln-dot" style={{ left: `${px(i)}%`, top: `${py(data[i].value)}%` }} />
<span className="gcdln-tip" data-below={py(data[i].value) < 32} style={{ left: `${Math.max(14, Math.min(86, px(i)))}%`, top: `${py(data[i].value)}%` }}>
<small>{data[i].label}</small>
<b>{format(data[i].value)}</b>
</span>
</div>
<div className="gcdln-axis" aria-hidden="true">
{data.map((p, n) => (
<span key={p.label} style={{ opacity: n % 2 === 0 || data.length < 8 ? 1 : 0 }}>{p.label}</span>
))}
</div>
</figure>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/datos-lineaEjemplo de uso de Gráfico de línea con mira
Después de copiar datos-linea.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { DatosLinea } from "./datos-linea";
export default function Ejemplo() {
return <DatosLinea title="Ejemplo" />;
}Propiedades de DatosLinea
DatosLinea acepta 5 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
data? | DatosLineaPunto[] | Opcional. |
title? | string | Opcional. |
format? | (value: number) => string | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 99 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Gráfico de línea con mira 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: img.
- Responde al teclado, no solo al ratón.
- Reacciona al puntero con eventos pointer (ratón, lápiz y táctil).
- Es un componente de cliente de Next.js: incluye la directiva "use client".