Tutorials

JavaScript-Farbmanipulation: Bibliotheken und Techniken

11 Min. Lesezeit

Farbe in Web-Anwendungen ist kein statischer Wert, den man aus einer Design-Datei übernimmt und unverändert lässt. Farben werden bei Hover heller, beim Drücken dunkler, passen sich Barrierefreiheits-Kontrast-Anforderungen an, animieren zwischen Zuständen und adaptieren sich an benutzerseitig gewählte Themes. All das erfordert programmatische Farbmanipulation — die Fähigkeit, Farbwerte in JavaScript zur Laufzeit zu parsen, zu transformieren und zu generieren.

Dieser Leitfaden zeigt, wie JavaScript Farben nativ verarbeitet, die Mathematik hinter gängigen Transformationen, einen Vergleich der drei meistverwendeten Bibliotheken und wie man ein minimales Farbhilfsprogramm ohne externe Abhängigkeiten entwickelt.


Hex, RGB und HSL in JavaScript parsen

JavaScript hat keinen eingebauten Farbtyp. Farben kommen als Strings an — "#FF5733", "rgb(255, 87, 51)", "hsl(11, 100%, 60%)" — und müssen vor jeder Berechnung selbst geparst werden.

Hex-Codes parsen

Ein Hex-Farbcode ist eine kompakte Kodierung von drei (oder vier, mit Alpha) Ein-Byte-Ganzzahlen in Basis 16. Das Parsen erfolgt durch Aufteilen des Strings und Aufruf von parseInt:

function parseHex(hex) {
  // Normalisieren: # entfernen und Kurzform expandieren (#F53 → #FF5533)
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;

  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
}

parseHex('#FF5733'); // { r: 255, g: 87, b: 51 }
parseHex('#F53');    // { r: 255, g: 85, b: 51 }

Der umgekehrte Weg — Ganzzahlen zurück in einen Hex-String — verwendet toString(16) mit Nullauffüllung:

function toHex({ r, g, b }) {
  return '#' + [r, g, b]
    .map(v => Math.round(v).toString(16).padStart(2, '0'))
    .join('')
    .toUpperCase();
}

toHex({ r: 255, g: 87, b: 51 }); // '#FF5733'

RGB-Strings parsen

RGB-Strings aus dem DOM oder CSS-in-JS kommen oft als "rgb(255, 87, 51)" an. Ein Regex extrahiert die drei Werte:

function parseRgb(str) {
  const match = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!match) throw new Error(`Ungültiges RGB: ${str}`);
  return {
    r: parseInt(match[1]),
    g: parseInt(match[2]),
    b: parseInt(match[3]),
  };
}

parseRgb('rgb(255, 87, 51)');      // { r: 255, g: 87, b: 51 }
parseRgb('rgba(255, 87, 51, 0.5)'); // { r: 255, g: 87, b: 51 }

HSL-Strings parsen

HSL-Strings — "hsl(11, 100%, 60%)" — erfordern das Extrahieren von Grad- und Prozentwerten:

function parseHsl(str) {
  const match = str.match(/hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%/);
  if (!match) throw new Error(`Ungültiges HSL: ${str}`);
  return {
    h: parseFloat(match[1]),
    s: parseFloat(match[2]),
    l: parseFloat(match[3]),
  };
}

Zwischen RGB und HSL konvertieren

Die meiste Farbmathematik arbeitet entweder in RGB (zum Mischen) oder HSL/HSV (für intuitive Anpassungen). Das Konvertieren zwischen ihnen ist die erste Fähigkeit, die man benötigt:

function rgbToHsl({ r, g, b }) {
  const rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn);
  const min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;

  if (d === 0) return { h: 0, s: 0, l: Math.round(l * 100) };

  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h;
  switch (max) {
    case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
    case gn: h = ((bn - rn) / d + 2) / 6; break;
    default: h = ((rn - gn) / d + 4) / 6;
  }

  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  };
}

rgbToHsl({ r: 255, g: 87, b: 51 }); // { h: 11, s: 100, l: 60 }

Farbmathematik: Aufhellen, Abdunkeln, Entsättigen

Hat man eine Farbe in HSL, ist das Anpassen reine Arithmetik. Die drei HSL-Achsen entsprechen direkt den Anpassungen, die Designer am häufigsten anfordern.

Aufhellen und Abdunkeln

Das Erhöhen oder Verringern des l-Kanals (Helligkeit) ist der direkte Ansatz:

function lighten(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.l = Math.min(100, hsl.l + amount);
  return hslToHex(hsl);
}

function darken(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.l = Math.max(0, hsl.l - amount);
  return hslToHex(hsl);
}

lighten('#FF5733', 15); // Helleres Korallenrot
darken('#FF5733', 15);  // Dunkleres Rotorange

Dies funktioniert gut für kleine Anpassungen. Für die Erzeugung einer vollständigen 50–950-Skala (wie Tailwind CSS es tut) ist die Mathematik aufwändiger, da die wahrgenommene Helligkeit in HSL nicht linear ist — der Farbton-Generator handhabt dies mit perzeptuell gewichteter Verteilung.

Entsättigung

Das Verringern des s-Kanals (Sättigung) gegen 0 macht jede Farbe grau:

function desaturate(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.s = Math.max(0, hsl.s - amount);
  return hslToHex(hsl);
}

function grayscale(hex) {
  return desaturate(hex, 100);
}

desaturate('#FF5733', 50); // Gedämpftes, wenig gesättigtes Orange
grayscale('#FF5733');      // Reines Grau mit derselben Helligkeit

Zwei Farben mischen

Lineare Interpolation im RGB-Farbraum ist die einfachste Mischmethode:

function mix(hex1, hex2, weight = 0.5) {
  const c1 = parseHex(hex1);
  const c2 = parseHex(hex2);
  return toHex({
    r: Math.round(c1.r * weight + c2.r * (1 - weight)),
    g: Math.round(c1.g * weight + c2.g * (1 - weight)),
    b: Math.round(c1.b * weight + c2.b * (1 - weight)),
  });
}

mix('#FF5733', '#FFFFFF', 0.7); // 70% Korallenrot, 30% Weiß → ein Tint
mix('#FF5733', '#000000', 0.7); // 70% Korallenrot, 30% Schwarz → ein Shade

RGB-Mischung erzeugt wahrnehmungsmäßig flache Ergebnisse zwischen Komplementärfarben. Für bessere Mittelpunkte bieten Bibliotheken wie chroma.js Mischung in OKLCH- oder Lab-Farbräumen an.

Kontrastverhältnis (WCAG)

Das WCAG-Kontrastverhältnis wird aus der relativen Leuchtkraft berechnet, nicht aus rohen Kanalwerten. Leuchtkraft erfordert Gamma-Korrektur — die Umkehrung der beim Speichern von sRGB-Werten angewendeten Kodierung:

function relativeLuminance({ r, g, b }) {
  const linearize = (v) => {
    const sRGB = v / 255;
    return sRGB <= 0.04045
      ? sRGB / 12.92
      : Math.pow((sRGB + 0.055) / 1.055, 2.4);
  };
  const R = linearize(r), G = linearize(g), B = linearize(b);
  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}

function contrastRatio(hex1, hex2) {
  const L1 = relativeLuminance(parseHex(hex1));
  const L2 = relativeLuminance(parseHex(hex2));
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

contrastRatio('#FF5733', '#FFFFFF'); // ~3,0 — WCAG AA für normalen Text nicht bestanden
contrastRatio('#000000', '#FFFFFF'); // 21,0 — maximaler Kontrast

WCAG AA erfordert 4,5:1 für normalen Text und 3:1 für großen Text. Jede Paarung mit dem Farbkonverter prüfen.


Beliebte Bibliotheken im Vergleich: chroma.js, culori, tinycolor2

Für alles jenseits einfacher Anpassungen spart eine dedizierte Bibliothek erheblich Zeit und vermeidet Randfall-Fehler in der Farbmathematik.

chroma.js

Größe: ~13 KB gzip | Reifegrad: 2013, aktiv gepflegt | Lizenz: BSD

chroma.js ist die bekannteste JavaScript-Farbbibliothek. Ihre API ist flüssig und verkettbar:

import chroma from 'chroma-js';

// Beliebiges Format parsen
const color = chroma('#FF5733');

// Anpassen
color.darken(1).hex();      // '#D93B10'
color.lighten(1).hex();     // '#FF8066'
color.saturate(0.5).hex();  // '#FF4719'
color.desaturate(0.5).hex();// '#F2653F'

// Konvertieren
color.rgb();  // [255, 87, 51]
color.hsl();  // [11, 1, 0,6]
color.lab();  // [52,8, 47,1, 44,7]
color.oklch();// [0,63, 0,19, 27,5]

// In verschiedenen Farbräumen mischen
chroma.mix('#FF5733', '#3B82F6', 0.5, 'rgb');   // In RGB (flacher Mittelpunkt)
chroma.mix('#FF5733', '#3B82F6', 0.5, 'oklch'); // In OKLCH (lebhafter Mittelpunkt)

// Skalenerzeugung
const scale = chroma.scale(['#FFF5F5', '#FF5733', '#7F0000'])
  .mode('oklch')
  .colors(9);

// Kontrast
chroma.contrast('#FF5733', '#FFFFFF'); // 3,0

chroma.js ist die richtige Wahl, wenn man eine umfassende, gut dokumentierte Bibliothek mit minimalem Setup und guter Unterstützung für Farbräum-Mischung benötigt.

culori

Größe: ~6 KB gzip (tree-shakeable) | Reifegrad: 2019, aktiv gepflegt | Lizenz: MIT

culori ist eine moderne, funktionale Bibliothek, die für Tree-Shaking konzipiert wurde. Jede Operation ist eine eigenständige Funktion — man importiert nur, was man verwendet:

import { parse, formatHex, oklch, interpolate, formatCss } from 'culori';

// Parsen
const color = parse('#FF5733'); // { mode: 'rgb', r: 1, g: 0,34, b: 0,2 }

// Nach OKLCH konvertieren
const inOklch = oklch(color); // { mode: 'oklch', l: 0,63, c: 0,19, h: 27,5 }

// Helligkeit anpassen
const lighter = { ...inOklch, l: inOklch.l + 0.1 };
formatHex(lighter); // '#FF8469'

// Interpolation für Gradienten/Animationen
const gradient = interpolate(['#FF5733', '#3B82F6'], 'oklch');
formatCss(gradient(0.5)); // Mittelpunktfarbe im CSS-Format

// Skalenerzeugung
import { samples } from 'culori';
const stops = samples(5).map(t => formatHex(gradient(t)));
// ['#FF5733', '#E85E56', '#9B6DE3', '#6186F0', '#3B82F6']

culori arbeitet mit Plain-Objects mit einer mode-Eigenschaft, was es unkompliziert macht, Farben zu serialisieren, im State zu speichern oder über ein Netzwerk zu senden. Es ist die beste Wahl für moderne TypeScript-Projekte, bei denen Bundle-Größe wichtig ist und Tree-Shaking aktiv ist.

tinycolor2

Größe: ~5 KB gzip | Reifegrad: 2012, stabil (weniger aktiv) | Lizenz: MIT

tinycolor2 ist der kleinste und permissivste Parser — er akzeptiert fast jedes Farbstring-Format, einschließlich CSS-Farbnamen, und funktioniert ohne Konfiguration:

import tinycolor from 'tinycolor2';

// Fast alles parsen
tinycolor('red').toHexString();         // '#FF0000'
tinycolor('hsl(11, 100%, 60%)').toHexString(); // '#FF5733'
tinycolor('#F53').toHexString();        // '#FF5533'

// Anpassen
tinycolor('#FF5733').lighten(15).toHexString();    // '#FF8966'
tinycolor('#FF5733').darken(15).toHexString();     // '#C92B04'
tinycolor('#FF5733').desaturate(30).toHexString(); // '#EB6643'
tinycolor('#FF5733').spin(180).toHexString();      // '#33AEff' (Komplementär)

// Lesbarkeit / Kontrast
tinycolor.readability('#FF5733', '#FFFFFF'); // 3,0
tinycolor.isReadable('#FF5733', '#FFFFFF');  // false (WCAG AA nicht bestanden)
tinycolor.isReadable('#FF5733', '#FFFFFF', { level: 'AA', size: 'large' }); // true

// Farbharmonien
tinycolor('#FF5733').triad();         // [TinyColor, TinyColor, TinyColor]
tinycolor('#FF5733').analogous();     // 6 analoge Farben
tinycolor('#FF5733').complement();    // Einzelne Komplementärfarbe

tinycolor2 ist die richtige Wahl für Projekte, die zuverlässiges Parsen von benutzerseitig eingegebenen Farbstrings (die in beliebigem Format vorliegen können) und grundlegende Manipulationen ohne eine größere Abhängigkeit benötigen.

Bibliotheksvergleich: Zusammenfassung

Funktion chroma.js culori tinycolor2
Bundle-Größe ~13 KB ~6 KB (tree-shakeable) ~5 KB
Farbräume RGB, HSL, Lab, LCH, OKLCH 20+ inkl. OKLCH, P3 RGB, HSL, HSV
API-Stil Flüssig/verkettbar Funktional Objektorientiert
TypeScript Community-Typen Eingebaut Community-Typen
Farbmischung RGB, HSL, Lab, OKLCH Jeder Farbraum Nur RGB
Am besten für Umfassende Nutzung, Dataviz Moderne TS-Bundles Parsen, einfache Ops

Ein Farbhilfsprogramm von Grund auf aufbauen

Für eine Produktionsanwendung, die nur Hex-Parsing, Aufhellen, Abdunkeln und Kontrastprüfung benötigt, ist ein zero-dependency-Hilfsprogramm oft die bessere architektonische Entscheidung. Hier ist eine vollständige Implementierung:

// color-utils.js

export function parseHex(hex) {
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;
  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
}

export function toHex({ r, g, b }) {
  return '#' + [r, g, b]
    .map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
    .join('').toUpperCase();
}

function rgbToHsl({ r, g, b }) {
  const rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;
  if (d === 0) return { h: 0, s: 0, l };
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h;
  switch (max) {
    case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
    case gn: h = ((bn - rn) / d + 2) / 6; break;
    default: h = ((rn - gn) / d + 4) / 6;
  }
  return { h, s, l };
}

function hslToRgb({ h, s, l }) {
  if (s === 0) {
    const v = Math.round(l * 255);
    return { r: v, g: v, b: v };
  }
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  const p = 2 * l - q;
  const hue2rgb = (p, q, t) => {
    if (t < 0) t += 1;
    if (t > 1) t -= 1;
    if (t < 1/6) return p + (q - p) * 6 * t;
    if (t < 1/2) return q;
    if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
    return p;
  };
  return {
    r: Math.round(hue2rgb(p, q, h + 1/3) * 255),
    g: Math.round(hue2rgb(p, q, h) * 255),
    b: Math.round(hue2rgb(p, q, h - 1/3) * 255),
  };
}

export function lighten(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, l: Math.min(1, hsl.l + amount / 100) }));
}

export function darken(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, l: Math.max(0, hsl.l - amount / 100) }));
}

export function desaturate(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, s: Math.max(0, hsl.s - amount / 100) }));
}

function linearize(v) {
  const sRGB = v / 255;
  return sRGB <= 0.04045 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
}

export function relativeLuminance(hex) {
  const { r, g, b } = parseHex(hex);
  return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}

export function contrastRatio(hex1, hex2) {
  const L1 = relativeLuminance(hex1);
  const L2 = relativeLuminance(hex2);
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

export function isWcagAA(foreground, background, largeText = false) {
  const ratio = contrastRatio(foreground, background);
  return ratio >= (largeText ? 3.0 : 4.5);
}

export function bestTextColor(background) {
  const L = relativeLuminance(background);
  return L > 0.179 ? '#000000' : '#FFFFFF';
}

Verwendung:

import { lighten, darken, contrastRatio, bestTextColor } from './color-utils.js';

const brand = '#FF5733';
const hover = darken(brand, 10);       // Dunkler für :hover
const light = lighten(brand, 30);      // Heller Tint für Hintergründe
const text = bestTextColor(brand);     // Schwarz oder Weiß für Text auf Markenfarbe

console.log(contrastRatio(text, brand)); // Sollte ≥ 4,5 sein

Performance-Überlegungen für Runtime-Farben

Aggressiv cachen

Farbberechnungen sind referenziell transparent — dieselbe Hex-Eingabe ergibt immer dieselbe Ausgabe. Ein einfaches Memoization-Wrapper vermeidet Neuberechnungen bei jedem Render:

function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn(...args));
    return cache.get(key);
  };
}

const lightenCached = memoize(lighten);
const contrastCached = memoize(contrastRatio);

CSS Custom Properties statt JavaScript

Für dynamisches Theming sollte man Farben nicht bei jeder Zustandsänderung in JavaScript neu berechnen. Die Palette einmal berechnen, in CSS Custom Properties schreiben und CSS die Verarbeitung aller Komponenten überlassen:

function applyTheme(brandHex) {
  const root = document.documentElement;
  root.style.setProperty('--brand', brandHex);
  root.style.setProperty('--brand-dark', darken(brandHex, 10));
  root.style.setProperty('--brand-light', lighten(brandHex, 30));
  root.style.setProperty('--on-brand', bestTextColor(brandHex));
}

// Einmalig aufgerufen, wenn sich die Markenfarbe ändert, nicht bei jedem Render
applyTheme('#FF5733');

String-Parsen in Render-Schleifen vermeiden

Das Parsen von "rgb(255, 87, 51)" innerhalb einer React-Render-Funktion, die 60-mal pro Sekunde läuft, ist verschwendend. Einmal parsen, das Ergebnis speichern und strukturierte Farbobjekte statt rohe Strings durch den Komponentenbaum übergeben.

// Aufwändig: parst bei jedem Aufruf
const color = chroma(colorString).darken(1).hex();

// Besser: einmal parsen, viele Male transformieren
const parsed = chroma(colorString);
const darkVariant = parsed.darken(1).hex();
const lightVariant = parsed.lighten(1).hex();

Für Runtime-Farbgenerierung in großem Maßstab — Farbwähler, die bei jeder Mausbewegung aktualisieren, oder Visualisierungen mit Hunderten berechneter Farben — ist culori die beste Wahl, weil seine funktionale, zustandslose Architektur und die schlanke Bundle-Größe den geringsten Overhead pro Aufruf haben.


Wichtige Erkenntnisse

  • JavaScript hat keinen nativen Farbtyp — Hex-, RGB- und HSL-Strings in strukturierte Objekte parsen, bevor Berechnungen durchgeführt werden.
  • HSL-Arithmetik (Anpassen von l für Helligkeit, s für Sättigung) deckt die meisten gängigen Design-Transformationen direkt ab.
  • Das WCAG-Kontrastverhältnis erfordert Leuchtdichteberechnung mit Gamma-Korrektur — keine Annäherung über Helligkeitskanal-Mathematik.
  • chroma.js ist die vollständigste Bibliothek mit ausgezeichneter Farbraum-Unterstützung einschließlich OKLCH-Mischung; culori ist die moderne tree-shakeable Wahl für TypeScript-Projekte; tinycolor2 ist der permissivste Parser für Benutzereingabe-Szenarien.
  • Ein zero-dependency-Hilfsprogramm ist für Anwendungen sinnvoll, die nur Parsen, Aufhellen/Abdunkeln und Kontrast benötigen — die vollständige Implementierung umfasst unter 100 Zeilen.
  • Berechnete Farben aggressiv cachen und Ergebnisse in CSS Custom Properties schreiben statt pro Render neu zu berechnen.
  • Den Farbkonverter verwenden, um jede Farbtransformation visuell zu überprüfen, und den Farbton-Generator, um vollständige 50–950 Tailwind-kompatible Skalen aus einem einzelnen Marken-Hex zu erzeugen.

Ähnliche Farben

Ähnliche Marken

Ähnliche Werkzeuge