Generador de paletas HSL con HTML, CSS y JavaScript
Inicia sesión para descargarConstruimos un generador de paletas en HSL desde cero: armonías clásicas (análoga, complementaria, triádica, etc.), UI accesible, bloqueo de muestras, copia a CSS y exportación a JSON. Incluye modo oscuro y buenas …
Contenido del tutorial ⌄
Objetivo: construir un generador de paletas en HSL que, a partir de un color base, cree esquemas de color clásicos (análoga, complementaria, complementaria dividida, triádica, tetrádica, cuadrada y monocromática). Añadimos bloqueo de muestras (🔒), copia a CSS y descarga en JSON.
Veremos la arquitectura (HTML/CSS/JS), el algoritmo de generación en HSL, detalles de accesibilidad y varias mejoras opcionales.
Arquitectura del proyecto
- HTML: estructura semántica, controles (color base, selección de esquema), acciones (copiar/descargar) y contenedor de paleta.
- CSS: tokens de diseño, layout responsivo, tarjetas de color con overlay y botones de acción accesibles.
- JS: estado centralizado (opciones, locks, último render), helpers HSL/HEX, generador por esquema, render reactivo y utilidades (copiar, descargar).
La UI expone controles dinámicos: Dispersión para esquemas que la usan (p.ej. Análoga/Complementaria dividida) y Pasos para Monocromática.
HTML base
El documento define la cabecera, el área de controles con role="region" y etiquetas accesibles, y el contenedor de la paleta (role="list" para las muestras). Los botones de acción permiten copiar CSS o descargar JSON.
Observa el badge de bloqueos y el botón “Limpiar” para gestionar los locks. Los controles extra (Dispersión/Pasos) se inyectan dinámicamente según el esquema.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Generador de Paletas - Paso 2</title>
<link rel="stylesheet" href="css/style.css">
<script defer src="js/main.js"></script>
</head>
<body>
<main class="wrapper">
<section class="palette-generator">
<header class="pg-header">
<h1>Generador de Paletas Algorítmico</h1>
<p>Elige un color base y un esquema para crear tu paleta.</p>
</header>
<div class="controls-area" role="region" aria-label="Controles de paleta">
<div class="row">
<label class="label" for="baseColorPicker">Color Base:</label>
<input type="color" id="baseColorPicker" value="#6a5af9" aria-label="Selector de color base" />
<button class="btn ghost" id="randomBtn" type="button" title="Color aleatorio">Aleatorio</button>
<div class="locks-box" aria-live="polite">
<span id="locksBadge" class="badge hard">Bloqueados: 0</span>
<button class="btn tiny" id="clearLocksBtn" type="button" title="Quitar todos los bloqueos">Limpiar</button>
</div>
</div>
<div class="row" id="rowScheme">
<label class="label" for="schemeSelect">Esquema:</label>
<select id="schemeSelect" class="select" aria-label="Esquema de color">
<option value="analogous">Análoga</option>
<option value="complementary">Complementaria</option>
<option value="split-complementary">Complementaria Dividida</option>
<option value="triadic">Triádica</option>
<option value="tetradic">Tetrádica</option>
<option value="square">Cuadrada</option>
<option value="monochrome">Monocromática</option>
</select>
<!-- Los controles extra (Dispersión/Pasos) se inyectan aquí solo si aplican -->
</div>
<div class="row row-actions">
<button class="btn" id="copyCssBtn" type="button">Copiar CSS</button>
<button class="btn" id="downloadJsonBtn" type="button">Descargar JSON</button>
</div>
</div>
<div id="paletteContainer" class="palette-container" role="list" aria-label="Colores generados"></div>
<p class="hint">En esquemas con Dispersión puedes usar 🔒 para fijar colores antes de mover el control.</p>
</section>
</main>
</body>
</html>
Estilos y tokens de diseño
El CSS usa variables para color base, fondos, texto y bordes. La cuadrícula de muestras (.palette-container) se adapta con auto-fit, y cada swatch tiene barra superior con acciones y un overlay para mostrar el HEX copiable.
Cuando un color está bloqueado, se resalta con un outline usando --primary. Los botones usan backdrop blur para contraste legible sobre el color.
:root {
--primary: #6a5af9;
--bg: #f6f7fb;
--card: #ffffff;
--text: #1a1a1a;
--muted: #6b7280;
--border: #e5e7eb;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
display: grid;
place-items: center;
min-height: 100vh;
padding: 24px;
}
.wrapper { width: min(980px, 100%); }
.palette-generator {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0,0,0,.06);
padding: 28px;
}
.pg-header h1 { margin: 0 0 6px; }
.pg-header p { margin: 0 0 24px; color: var(--muted); }
.controls-area { display: grid; gap: 16px; margin-bottom: 24px; }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.label { font-weight: 600; font-size: 14px; }
.label.small { font-weight: 500; font-size: 13px; color: var(--muted); }
input[type="color"] {
appearance: none; -webkit-appearance: none; border: none; width: 44px; height: 44px;
border-radius: 12px; background-color: transparent; cursor: pointer;
}
input[type="color"]::-webkit-color-swatch { border-radius: 12px; border: 2px solid var(--border); }
input[type="color"]::-moz-color-swatch { border-radius: 12px; border: 2px solid var(--border); }
.select {
height: 40px; padding: 0 12px; border: 1px solid var(--border); border-radius: 10px;
background-color: #fff; font-family: inherit; font-weight: 500; font-size: 14px;
}
.control-inline { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.control-inline input[type="range"] { width: 160px; }
.control-inline .value { min-width: 34px; text-align: right; color: var(--muted); font-size: 14px; }
.locks-box { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.badge {
font-size: 12px; padding: 4px 10px; border-radius: 999px; background: rgba(0,0,0,.06);
border: 1px solid var(--border); color: #374151; font-weight: 500;
}
.badge.hard { background: rgba(106,90,249,.08); border-color: rgba(106,90,249,.25); color: #4338ca; }
.btn {
height: 40px; padding: 0 16px; border-radius: 10px; border: 1px solid var(--border);
background-color: #fff; cursor: pointer; font-weight: 600; font-size: 14px;
transition: transform .06s ease, box-shadow .15s ease;
}
.btn:active { transform: translateY(1px); }
.btn.ghost { background-color: transparent; }
.btn.tiny { height: 32px; padding: 0 12px; font-size: 13px; }
.row-actions { justify-content: flex-end; }
.palette-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.swatch {
position: relative; border-radius: 14px; overflow: hidden; min-height: 140px;
border: 1px solid var(--border);
}
.swatch.locked { outline: 3px solid var(--primary); outline-offset: 2px; }
.swatch .topbar { position: absolute; inset: 10px 10px auto auto; display: flex; gap: 8px; }
.icon-btn {
border: none; background: rgba(255,255,255,.7);
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
border-radius: 8px; width: 36px; height: 36px; display: grid; place-items: center;
cursor: pointer; color: var(--text); font-size: 18px;
}
.icon-btn[aria-pressed="true"] { background: rgba(0,0,0,.6); color: #fff; }
.swatch .info {
position: absolute; left: 0; right: 0; bottom: 0;
background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,.4) 100%);
color: #fff; padding: 12px; text-align: center;
}
.copyable { cursor: pointer; user-select: all; font-weight: 600; letter-spacing: .3px; }
.hint { margin-top: 16px; color: var(--muted); font-size: 13px; text-align: center; }
JavaScript: estado, algoritmos y render
La app cachea referencias del DOM y mantiene un estado con locks, opciones (spread/steps) y el último render. La generación trabaja en HSL y convierte a HEX para renderizar. Los esquemas alteran el hue con sumas y wrap por 360°, y para Monocromática variamos lightness y suavemente la saturation.
El render recorre los hexes, crea cada swatch (botón de candado solo si el esquema lo permite) y habilita la copia del HEX con feedback. Además, expone utilidades para copiar el CSS de variables y descargar un JSON de la paleta.
document.addEventListener('DOMContentLoaded', () => {
// --- DOM ---
const dom = {
colorPicker: document.getElementById('baseColorPicker'),
schemeSelect: document.getElementById('schemeSelect'),
rowScheme: document.getElementById('rowScheme'),
paletteContainer: document.getElementById('paletteContainer'),
copyCssBtn: document.getElementById('copyCssBtn'),
downloadJsonBtn: document.getElementById('downloadJsonBtn'),
randomBtn: document.getElementById('randomBtn'),
clearLocksBtn: document.getElementById('clearLocksBtn'),
locksBadge: document.getElementById('locksBadge'),
locksBox: document.querySelector('.locks-box'),
};
// --- ESTADO ---
const state = {
locks: {}, // { index: '#HEX' } (solo se usa cuando el esquema tiene Dispersión)
lastRenderedHexes: [],
options: { // valores persistentes
spread: 30, // 5..60
steps: 5, // 3..7
},
};
// --- HELPERS DE UI ---
const lockingSchemes = new Set(['analogous', 'split-complementary']);
const SPREAD_SCHEMES = new Set(['analogous', 'split-complementary']);
const isLockingEnabled = () => lockingSchemes.has(dom.schemeSelect.value);
function toggleLocksUI() {
const enabled = isLockingEnabled();
dom.locksBox.style.display = enabled ? 'flex' : 'none';
if (!enabled && Object.keys(state.locks).length) {
state.locks = {}; // al deshabilitar, purga locks para evitar confusiones
updateLocksBadge();
}
}
// --- GENERAR + RENDER ---
function generateAndRender() {
const baseColorHex = dom.colorPicker.value.toUpperCase();
const scheme = dom.schemeSelect.value;
const opts = {
spread: Number(state.options.spread),
steps: Number(state.options.steps),
};
const baseHsl = hexToHsl(baseColorHex);
const generatedHsl = generateScheme(baseHsl, scheme, opts);
const generatedHex = generatedHsl.map(hsl =>
hslToHex(hsl.h, hsl.s, hsl.l).toUpperCase()
);
// Locks sólo si están habilitados en el esquema actual
const finalHexes = isLockingEnabled()
? generatedHex.map((hex, i) => state.locks[i] || hex)
: generatedHex;
if (!isLockingEnabled()) {
state.locks = {}; // asegúrate de mantener limpio cuando no se permite lock
} else {
purgeOrphanLocks(finalHexes.length);
}
state.lastRenderedHexes = finalHexes;
renderPalette(finalHexes);
}
function renderPalette(hexes) {
dom.paletteContainer.innerHTML = '';
const allowLocks = isLockingEnabled();
hexes.forEach((hex, index) => {
const isLocked = allowLocks && !!state.locks[index];
const swatch = document.createElement('div');
swatch.className = `swatch ${isLocked ? 'locked' : ''}`;
swatch.style.backgroundColor = hex;
swatch.setAttribute('role', 'listitem');
const topbar = document.createElement('div');
topbar.className = 'topbar';
// Candadito solo si el esquema permite bloqueo (esquemas con Dispersión)
if (allowLocks) {
const lockBtn = document.createElement('button');
lockBtn.className = 'icon-btn';
lockBtn.type = 'button';
lockBtn.setAttribute('aria-pressed', String(isLocked));
lockBtn.title = isLocked ? 'Desbloquear' : 'Bloquear';
lockBtn.textContent = isLocked ? '🔒' : '🔓';
lockBtn.addEventListener('click', () => toggleLock(index, hex));
topbar.appendChild(lockBtn);
}
const info = document.createElement('div');
info.className = 'info';
const hexCode = document.createElement('span');
hexCode.className = 'copyable';
hexCode.textContent = hex;
hexCode.tabIndex = 0;
hexCode.title = 'Clic para copiar';
hexCode.addEventListener('click', () => copyHexWithFeedback(hex, hexCode));
hexCode.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
copyHexWithFeedback(hex, hexCode);
}
});
info.appendChild(hexCode);
swatch.appendChild(topbar);
swatch.appendChild(info);
dom.paletteContainer.appendChild(swatch);
});
updateLocksBadge();
}
// --- ESQUEMAS ---
function generateScheme(baseHsl, scheme, { spread, steps }) {
const { h, s, l } = baseHsl;
const normH = val => ((val % 360) + 360) % 360;
switch (scheme) {
case 'analogous':
return [
{ h: normH(h - spread), s, l },
{ h, s, l },
{ h: normH(h + spread), s, l }
];
case 'complementary':
return [
{ h, s, l },
{ h: normH(h + 180), s, l }
];
case 'split-complementary':
return [
{ h, s, l },
{ h: normH(h + 180 - spread), s, l },
{ h: normH(h + 180 + spread), s, l }
];
case 'triadic':
return [
{ h, s, l },
{ h: normH(h + 120), s, l },
{ h: normH(h + 240), s, l }
];
case 'tetradic':
return [
{ h, s, l },
{ h: normH(h + 60), s, l },
{ h: normH(h + 180), s, l },
{ h: normH(h + 240), s, l }
];
case 'square':
return [
{ h, s, l },
{ h: normH(h + 90), s, l },
{ h: normH(h + 180), s, l },
{ h: normH(h + 270), s, l }
];
case 'monochrome': {
const total = clamp(Math.round(steps), 3, 7);
const lStart = 12, lEnd = 88;
const lDelta = (lEnd - lStart) / (total - 1);
const arr = [];
for (let i = 0; i < total; i++) {
const li = Math.round(lStart + i * lDelta);
const si = clamp(Math.round(s * 0.9 + (i - (total - 1) / 2) * 3), 6, 96);
arr.push({ h, s: si, l: li });
}
return arr;
}
default:
return [{ h, s, l }];
}
}
// --- CONTROLES DINÁMICOS (Dispersión/Pasos solo si aplican) ---
function renderOptionControls() {
// Elimina cualquier control previo
dom.rowScheme.querySelectorAll('.control-inline').forEach(el => el.remove());
const scheme = dom.schemeSelect.value;
// Dispersión para esquemas que la usan
if (SPREAD_SCHEMES.has(scheme)) {
const wrap = document.createElement('div');
wrap.className = 'control-inline';
wrap.id = 'spreadCtrl';
const lab = document.createElement('label');
lab.className = 'label small';
lab.setAttribute('for', 'spreadInput');
lab.textContent = 'Dispersión (°)';
const range = document.createElement('input');
range.id = 'spreadInput';
range.type = 'range';
range.min = '5';
range.max = '60';
range.step = '1';
range.value = String(state.options.spread);
const val = document.createElement('span');
val.className = 'value';
val.id = 'spreadValue';
val.textContent = `${state.options.spread}°`;
range.addEventListener('input', () => {
state.options.spread = Number(range.value);
val.textContent = `${range.value}°`;
generateAndRender();
});
wrap.appendChild(lab);
wrap.appendChild(range);
wrap.appendChild(val);
dom.rowScheme.appendChild(wrap);
}
// Pasos solo para monocromática
if (scheme === 'monochrome') {
const wrap = document.createElement('div');
wrap.className = 'control-inline';
wrap.id = 'stepsCtrl';
const lab = document.createElement('label');
lab.className = 'label small';
lab.setAttribute('for', 'stepsInput');
lab.textContent = 'Pasos';
const range = document.createElement('input');
range.id = 'stepsInput';
range.type = 'range';
range.min = '3';
range.max = '7';
range.step = '1';
range.value = String(state.options.steps);
const val = document.createElement('span');
val.className = 'value';
val.id = 'stepsValue';
val.textContent = String(state.options.steps);
range.addEventListener('input', () => {
state.options.steps = Number(range.value);
val.textContent = range.value;
generateAndRender();
});
wrap.appendChild(lab);
wrap.appendChild(range);
wrap.appendChild(val);
dom.rowScheme.appendChild(wrap);
}
// Mostrar/ocultar locks UI acorde al esquema
toggleLocksUI();
}
// --- EVENTOS BÁSICOS ---
function toggleLock(index, hex) {
if (!isLockingEnabled()) return; // seguridad extra
if (state.locks[index]) delete state.locks[index];
else state.locks[index] = hex;
generateAndRender();
}
function updateLocksBadge() {
dom.locksBadge.textContent = `Bloqueados: ${Object.keys(state.locks).length}`;
}
dom.colorPicker.addEventListener('input', generateAndRender);
dom.schemeSelect.addEventListener('change', () => {
renderOptionControls(); // inyecta/retira controles según esquema
generateAndRender();
});
dom.randomBtn.addEventListener('click', () => {
dom.colorPicker.value = randomHex();
generateAndRender();
});
dom.clearLocksBtn.addEventListener('click', () => {
state.locks = {};
generateAndRender();
});
dom.copyCssBtn.addEventListener('click', async (e) => {
const css = exportCssRoot(state.lastRenderedHexes);
await copyToClipboard(css);
flashButton(e.target, '¡Copiado!');
});
dom.downloadJsonBtn.addEventListener('click', () => {
const payload = JSON.stringify({ colors: state.lastRenderedHexes }, null, 2);
downloadFile('palette.json', payload, 'application/json');
});
// --- UTILIDADES ---
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function hexToHsl(hex) {
const { r, g, b } = hexToRgb(hex);
let r1 = r / 255, g1 = g / 255, b1 = b / 255;
const max = Math.max(r1, g1, b1), min = Math.min(r1, g1, b1);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r1: h = (g1 - b1) / d + (g1 < b1 ? 6 : 0); break;
case g1: h = (b1 - r1) / d + 2; break;
case b1: h = (r1 - g1) / d + 4; break;
}
h /= 6;
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
function hslToHex(h, s, l) {
s /= 100; l /= 100;
const k = n => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1));
const r = Math.round(255 * f(0));
const g = Math.round(255 * f(8));
const b = Math.round(255 * f(4));
return rgbToHex(r, g, b);
}
function hexToRgb(hex) {
let h = (hex || '#000000').replace('#', '').trim();
if (h.length === 3) h = [...h].map(x => x + x).join('');
const num = parseInt(h, 16);
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
}
function rgbToHex(r, g, b) {
return `#${((1 << 24) + (r << 16) + (g << 8) + b)
.toString(16)
.slice(1)
.toUpperCase()}`;
}
function randomHex() {
return '#' + Math.floor(Math.random() * 0xFFFFFF)
.toString(16)
.padStart(6, '0')
.toUpperCase();
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
return true;
}
}
async function copyHexWithFeedback(hex, element) {
const ok = await copyToClipboard(hex);
if (!ok) return;
const original = element.textContent;
element.textContent = '¡Copiado!';
setTimeout(() => { element.textContent = original; }, 1200);
}
function flashButton(btn, text = '¡Hecho!') {
const original = btn.textContent;
btn.disabled = true;
btn.textContent = text;
setTimeout(() => {
btn.disabled = false;
btn.textContent = original;
}, 900);
}
function downloadFile(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.createObjectURL(url);
}
function exportCssRoot(hexes) {
const vars = hexes.map((h, i) => ` --color-${i + 1}: ${h};`).join('\n');
return `:root {\n${vars}\n}`;
}
function purgeOrphanLocks(length) {
const kept = {};
Object.keys(state.locks).forEach(k => {
const idx = Number(k);
if (Number.isInteger(idx) && idx >= 0 && idx < length) {
kept[idx] = state.locks[idx];
}
});
state.locks = kept;
}
// --- INIT ---
renderOptionControls(); // inyecta solo los controles que aplican
toggleLocksUI(); // muestra/oculta caja de locks según el esquema
generateAndRender();
});
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!