Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

Selector de color

Muestras con flechas, tono continuo y campo hexadecimal validado; en el estilo limpio de una herramienta de diseño.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

selector-color.tsx119 líneas
Descargar
"use client";

import { useId, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";

/** Selector de color: muestras en cuadrícula con flechas, tono continuo y valor hexadecimal copiable. Estilo minimalista de herramienta de diseño. React + TypeScript, sin dependencias. */
export type SelectorColorProps = {
  value?: string;
  defaultValue?: string;
  swatches?: string[];
  onChange?: (hex: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcscl-root { box-sizing: border-box; display: grid; gap: 16px; width: 100%; max-width: 320px; padding: 16px; border: 1px solid #e4e4e7; border-radius: 16px; background: #fff; color: #18181b; font: 500 13px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; box-shadow: 0 10px 30px -14px rgba(0, 0, 0, .25); }
.gcscl-root *, .gcscl-root *::before, .gcscl-root *::after { box-sizing: border-box; }
.gcscl-root button, .gcscl-root input { font: inherit; }
@media (prefers-reduced-motion: reduce) { .gcscl-root *, .gcscl-root *::before, .gcscl-root *::after { transition-duration: .01ms !important; } }
.gcscl-preview { height: 84px; border-radius: 10px; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .08); transition: background .25s; }
.gcscl-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: 8px; }
.gcscl-sw { aspect-ratio: 1; padding: 0; border: 0; border-radius: 8px; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12); transition: transform .18s cubic-bezier(.16, 1, .3, 1); }
.gcscl-sw:hover { transform: scale(1.12); }
.gcscl-sw[aria-checked="true"] { box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12), 0 0 0 2px #fff, 0 0 0 4px #18181b; }
.gcscl-sw:focus-visible { outline: 2px solid #2563eb; outline-offset: 3px; }
.gcscl-hue { -webkit-appearance: none; appearance: none; width: 100%; height: 12px; margin: 0; border-radius: 6px; background: linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); outline: 0; }
.gcscl-hue::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; border: 3px solid #fff; background: transparent; box-shadow: 0 0 0 1px rgba(0, 0, 0, .35), 0 2px 5px rgba(0, 0, 0, .3); cursor: grab; }
.gcscl-hue::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; border: 3px solid #fff; background: transparent; box-shadow: 0 0 0 1px rgba(0, 0, 0, .35); }
.gcscl-hue:focus-visible::-webkit-slider-thumb { outline: 2px solid #2563eb; outline-offset: 2px; }
.gcscl-row { display: flex; align-items: center; gap: 10px; }
.gcscl-hex { flex: 1; min-width: 0; height: 38px; padding: 0 12px; border: 1px solid #d4d4d8; border-radius: 8px; font: 500 14px/1 ui-monospace, "SF Mono", Menlo, monospace; text-transform: uppercase; color: inherit; background: #fafafa; }
.gcscl-hex:focus-visible { outline: 2px solid #2563eb; outline-offset: 1px; }
.gcscl-hex[aria-invalid="true"] { border-color: #dc2626; }`;

const DEFAULT_SWATCHES = ["#ef4444", "#f97316", "#f59e0b", "#eab308", "#84cc16", "#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#ec4899", "#f43f5e"];

function hexToHue(hex: string) {
  const n = parseInt(hex.slice(1), 16);
  const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
  if (!d) return 0;
  const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
  return Math.round(((h * 60) + 360) % 360);
}
function hslToHex(h: number, s: number, l: number) {
  const k = (n: number) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n: number) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))));
  return `#${[f(0), f(8), f(4)].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
}

export function SelectorColor({ value, defaultValue = "#3b82f6", swatches = DEFAULT_SWATCHES, onChange, className = "", style }: SelectorColorProps) {
  const id = useId();
  const [inner, setInner] = useState(defaultValue);
  const color = (value ?? inner).toLowerCase();
  const [draft, setDraft] = useState<string | null>(null);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  function set(hex: string) {
    setInner(hex);
    setDraft(null);
    onChange?.(hex);
  }
  function key(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : event.key === "ArrowDown" ? 8 : event.key === "ArrowUp" ? -8 : 0;
    if (!step) return;
    event.preventDefault();
    const next = Math.max(0, Math.min(swatches.length - 1, index + step));
    set(swatches[next]);
    refs.current[next]?.focus();
  }
  const selectedIndex = swatches.findIndex((swatch) => swatch.toLowerCase() === color);
  const text = draft ?? color;
  const valid = /^#[0-9a-f]{6}$/i.test(text);

  return (
    <div className={`gcscl-root ${className}`} style={style}>
      <style>{CSS}</style>
      <div className="gcscl-preview" style={{ background: color }} aria-hidden="true" />
      <div className="gcscl-grid" role="radiogroup" aria-label="Colores">
        {swatches.map((swatch, index) => (
          <button
            key={swatch}
            ref={(node) => {
              refs.current[index] = node;
            }}
            className="gcscl-sw"
            type="button"
            role="radio"
            aria-checked={swatch.toLowerCase() === color}
            aria-label={swatch}
            tabIndex={swatch.toLowerCase() === color || (selectedIndex < 0 && index === 0) ? 0 : -1}
            style={{ background: swatch }}
            onClick={() => set(swatch)}
            onKeyDown={(event) => key(event, index)}
          />
        ))}
      </div>
      <input className="gcscl-hue" type="range" min={0} max={360} value={hexToHue(color)} aria-label="Tono" onChange={(event) => set(hslToHex(Number(event.target.value), 0.8, 0.55))} />
      <div className="gcscl-row">
        <label htmlFor={`${id}-hex`}>HEX</label>
        <input
          id={`${id}-hex`}
          className="gcscl-hex"
          value={text}
          maxLength={7}
          aria-invalid={!valid}
          onChange={(event) => {
            const next = event.target.value.startsWith("#") ? event.target.value : `#${event.target.value}`;
            setDraft(next);
            if (/^#[0-9a-f]{6}$/i.test(next)) set(next.toLowerCase());
          }}
          onBlur={() => setDraft(null)}
        />
      </div>
    </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/selector-color

Ejemplo de uso de Selector de color

Después de copiar selector-color.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
import { SelectorColor } from "./selector-color";

export default function Ejemplo() {
  return <SelectorColor value="Ejemplo" />;
}

Propiedades de SelectorColor

SelectorColor acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
value?stringOpcional.
defaultValue?stringOpcional.
swatches?string[]Opcional.
onChange?(hex: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 119 líneas (6 KB) y no necesita @keyframes. Esto es lo que hace Selector de color 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: radiogroup, radio.
  • Responde al teclado, no solo al ratón.
  • 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)