Desarrollo web

Generador de paletas HSL con HTML, CSS y JavaScript

Construimos 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();
});

Estás viendo solo el 60% del contenido. Hazte Premium para acceder al tutorial completo.

Comunidad

Comentarios y valoraciones

No hay comentarios aún. ¡Sé el primero en opinar!