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.
"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.
npx shadcn@latest add https://www.gassierconsulting.com/r/selector-colorEjemplo de uso de Selector de color
Después de copiar selector-color.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
value? | string | Opcional. |
defaultValue? | string | Opcional. |
swatches? | string[] | Opcional. |
onChange? | (hex: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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".