Ir al contenido
Evaluar mi proyecto
Componentes · MicrointeraccionesDisponible

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.

perilla-rotatoria.tsx96 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/perilla-rotatoria

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
label?stringOpcional.
min?numberOpcional.
max?numberOpcional.
value?numberOpcional.
defaultValue?numberOpcional.
onChange?(value: number) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)