Perilla rotatoria de aluminio
Perilla skeuomórfica con marcas encendidas, aguja y pantalla LCD; se gira arrastrando, con la rueda o con flechas.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
/** Perilla rotatoria skeuomórfica de aluminio con marcas, aguja de luz y valor digital. Se gira arrastrando en círculo, con la rueda o con flechas (slider accesible). React + TypeScript, sin dependencias. */
export type PerillaRotatoriaProps = {
label?: string;
min?: number;
max?: number;
value?: number;
defaultValue?: number;
onChange?: (value: number) => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcmpr-root { box-sizing: border-box; display: inline-grid; justify-items: center; gap: 14px; padding: 22px 26px 18px; border-radius: 22px; background: linear-gradient(180deg, #34363c, #26282d); color: #d8dbe3; font: 700 11px/1 "Helvetica Neue", Arial, ui-sans-serif, sans-serif; letter-spacing: .16em; text-transform: uppercase; box-shadow: inset 0 1px 0 rgba(255, 255, 255, .15), inset 0 -2px 6px rgba(0, 0, 0, .5), 0 18px 26px -10px rgba(0, 0, 0, .7); }
.gcmpr-root *, .gcmpr-root *::before, .gcmpr-root *::after { box-sizing: border-box; }
.gcmpr-root :focus-visible { outline: 3px solid #5aa9ff; outline-offset: 4px; border-radius: 50%; }
.gcmpr-wrap { position: relative; width: 150px; height: 150px; }
.gcmpr-ticks { position: absolute; inset: 0; width: 100%; height: 100%; }
.gcmpr-ticks line { stroke: #6c707b; stroke-width: 1.5; stroke-linecap: round; }
.gcmpr-ticks line[data-on="true"] { stroke: #7dffa1; filter: drop-shadow(0 0 3px #7dffa1); }
.gcmpr-knob { position: absolute; inset: 22px; border-radius: 50%; cursor: grab; touch-action: none; outline: 0; background: conic-gradient(from 0deg, #f5f6f8, #b6b9c2, #f5f6f8, #a9acb6, #f5f6f8, #b6b9c2, #f5f6f8); box-shadow: inset 0 0 0 2px rgba(0, 0, 0, .35), 0 8px 12px rgba(0, 0, 0, .6), 0 2px 0 rgba(255, 255, 255, .3) inset; transition: box-shadow .15s; }
.gcmpr-knob:active { cursor: grabbing; }
.gcmpr-knob::before { content: ""; position: absolute; inset: 7px; border-radius: 50%; background: repeating-conic-gradient(from 0deg, rgba(255, 255, 255, .22) 0 2deg, rgba(0, 0, 0, .05) 2deg 4deg), linear-gradient(145deg, #e8eaee, #b8bbc4); box-shadow: inset 0 2px 4px rgba(255, 255, 255, .8), inset 0 -3px 6px rgba(0, 0, 0, .25); }
.gcmpr-needle { position: absolute; left: 50%; top: 10px; width: 5px; height: 20px; margin-left: -2.5px; border-radius: 3px; background: #1d1f24; box-shadow: 0 1px 0 rgba(255, 255, 255, .6); }
.gcmpr-root:focus-within .gcmpr-wrap { filter: drop-shadow(0 0 0 transparent); }
.gcmpr-lcd { min-width: 86px; padding: 8px 10px; border-radius: 6px; background: #0c1a0f; color: #7dffa1; text-align: center; font: 600 20px/1 ui-monospace, "SF Mono", Menlo, monospace; letter-spacing: .08em; text-shadow: 0 0 8px rgba(125, 255, 161, .7); box-shadow: inset 0 2px 5px rgba(0, 0, 0, .9), 0 1px 0 rgba(255, 255, 255, .15); font-variant-numeric: tabular-nums; }`;
export function PerillaRotatoria({ label = "Ganancia", min = 0, max = 100, value, defaultValue = 62, onChange, className = "", style }: PerillaRotatoriaProps) {
const [inner, setInner] = useState(defaultValue);
const current = Math.max(min, Math.min(max, value ?? inner));
const ref = useRef<HTMLDivElement>(null);
const SWEEP = 270;
const angle = ((current - min) / (max - min)) * SWEEP - SWEEP / 2;
function set(next: number) {
const clamped = Math.round(Math.max(min, Math.min(max, next)));
setInner(clamped);
onChange?.(clamped);
}
function fromPointer(event: PointerEvent<HTMLDivElement>) {
const rect = ref.current!.getBoundingClientRect();
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
let deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
deg = Math.max(-SWEEP / 2, Math.min(SWEEP / 2, deg));
set(min + ((deg + SWEEP / 2) / SWEEP) * (max - min));
}
function key(event: KeyboardEvent) {
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowUp" || event.key === "ArrowRight") { event.preventDefault(); set(current + step); }
else if (event.key === "ArrowDown" || event.key === "ArrowLeft") { event.preventDefault(); set(current - step); }
else if (event.key === "Home") { event.preventDefault(); set(min); }
else if (event.key === "End") { event.preventDefault(); set(max); }
}
const ticks = Array.from({ length: 21 }, (_, i) => {
const a = (i / 20) * SWEEP - SWEEP / 2;
return { a, on: (i / 20) * (max - min) + min <= current };
});
return (
<div className={`gcmpr-root ${className}`} style={style}>
<style>{CSS}</style>
<div className="gcmpr-wrap">
<svg className="gcmpr-ticks" viewBox="0 0 150 150" aria-hidden="true">
{ticks.map((tick, i) => (
<line key={i} x1="75" y1="4" x2="75" y2={i % 5 === 0 ? 14 : 10} data-on={tick.on} transform={`rotate(${tick.a} 75 75)`} />
))}
</svg>
<div
ref={ref}
className="gcmpr-knob"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current}
onKeyDown={key}
onPointerDown={(event) => { event.currentTarget.setPointerCapture(event.pointerId); fromPointer(event); }}
onPointerMove={(event) => event.buttons === 1 && fromPointer(event)}
onWheel={(event) => { event.preventDefault(); set(current + (event.deltaY < 0 ? 2 : -2)); }}
style={{ transform: `rotate(${angle}deg)`, transition: "transform .08s linear" }}
>
<span className="gcmpr-needle" />
</div>
</div>
<span className="gcmpr-lcd" aria-hidden="true">{String(current).padStart(3, "0")}</span>
<span aria-hidden="true">{label}</span>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/perilla-rotatoriaEjemplo de uso de Perilla rotatoria de aluminio
Después de copiar perilla-rotatoria.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { PerillaRotatoria } from "./perilla-rotatoria";
export default function Ejemplo() {
return <PerillaRotatoria label="Ejemplo" />;
}Propiedades de PerillaRotatoria
PerillaRotatoria acepta 8 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
label? | string | Opcional. |
min? | number | Opcional. |
max? | number | Opcional. |
value? | number | Opcional. |
defaultValue? | number | Opcional. |
onChange? | (value: number) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 96 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Perilla rotatoria de aluminio por quien lo usa:
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: slider.
- 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".