Der Moment, in dem ein Farbsystem kippt, ist selten das Redesign. Es ist der Tag, an dem jemand im Backend eine Markenfarbe ändern darf – und die Schrift darauf weiß bleibt, weil vor zwei Jahren jemand entschieden hat, dass sie weiß ist.
Warum Kontrast Handarbeit war
Sobald eine Anwendung Farben nicht mehr fest verdrahtet, sondern entgegennimmt – aus einem Theme-Editor, aus Mandantendaten, aus einer Kategorie-Tabelle –, gibt es genau eine ehrliche Aussage über die Textfarbe darauf: Man weiß sie zur Entwicklungszeit nicht. Trotzdem musste man sie irgendwo hinschreiben.
Der klassische Weg war eine Funktion im Präprozessor. Die rechnet zuverlässig – aber nur mit dem, was beim Kompilieren schon feststeht:
// Entscheidung zur Compile-Zeit: funktioniert nur für Farben,
// die im Stylesheet stehen.
@function textfarbe($hintergrund) {
@return if(lightness($hintergrund) > 55%, #111, #fff);
}
.badge--marke {
background: $marke;
color: textfarbe($marke);
}
Kommt die Farbe erst im Browser an, hilft das nichts. Also wanderte die Rechnung ins JavaScript: eine Farbbibliothek einbinden, Relativhelligkeit bestimmen, Klasse setzen. Das funktioniert, kostet aber Bundle, läuft im Main Thread und sorgt bei serverseitig gerendertem HTML für genau den Effekt, den niemand will – die Kachel erscheint kurz mit falscher Schriftfarbe und korrigiert sich sichtbar.
Es gab auch die kreative Variante: Farbe in --r, --g,
--b zerlegen, die Helligkeit über calc() annähern und das
Ergebnis auf 0 oder 1 klemmen. Ein schöner Trick, den ein halbes Jahr später niemand
mehr anfassen will.
Was contrast-color() macht
Seit April 2026 ist die Sache deutlich langweiliger, und das ist die gute Nachricht.
contrast-color() nimmt eine Farbe entgegen und gibt Schwarz oder Weiß
zurück – je nachdem, was auf dieser Farbe den höheren Kontrast erzeugt. Die Rechnung
passiert in der Style-Berechnung des Browsers, nicht in deinem Code:
.badge--marke {
/* Kommt aus dem Backend, dem Theme-Editor, wo auch immer her. */
background: var(--marke);
color: contrast-color(var(--marke));
}
Die Kacheln unten sind kein Screenshot, sondern genau dieses Stylesheet. Gesetzt ist jeweils nur der Hintergrund – die Schriftfarbe entscheidet dein Browser:
#e6195d
#3b82f6
#fbbf24
#0f766e
#a3e635
#6b21a8
Wichtig für die Erwartungshaltung: Die Funktion sucht sich keine hübsche Farbe aus deiner Palette. Sie liefert Schwarz oder Weiß, sonst nichts. Der Entwurf für die nächste Stufe der Spezifikation sieht Kandidatenlisten und ein Zielverhältnis vor – also „nimm die erste dieser vier Farben, die 4,5:1 schafft“ –, das ist aber noch nicht implementiert. Wer die Funktion heute einsetzt, plant mit Schwarz und Weiß.
Unterstützt wird sie mittlerweile von allen drei Engines: Safari seit Version 26, Firefox seit 146 und Chrome seit 147. Damit gilt sie als „Baseline: newly available“ – neu genug, dass ältere Geräte im Bestand noch danebenliegen, aber alt genug, um sie nicht mehr als Experiment zu behandeln.
Die Grenzen der Automatik
Die Funktion nimmt dir die Entscheidung ab, nicht das Nachdenken. Fünf Punkte sind in der Praxis relevant:
- Nur flache Farbwerte. Ein Verlauf oder ein Bild als Argument ist ein Syntaxfehler, kein stiller Fallback.
- Bewertet wird nur der übergebene Farbwert, nicht der tatsächlich sichtbare Aufbau – bei halbtransparenten Flächen rechnet die Funktion also an der Realität vorbei.
- Bei exaktem Gleichstand zwischen Schwarz und Weiß gewinnt laut Spezifikation Weiß.
- Im Windows-Kontrastmodus greifen die Systemfarben und überschreiben das Ergebnis komplett.
- Für AAA (7:1) gibt es eine echte Lücke: Hintergründe mit rund 10 bis 30 Prozent Relativhelligkeit erreichen das Verhältnis weder mit Schwarz noch mit Weiß.
Der Punkt, der im Alltag am häufigsten auffällt, ist ein anderer: Weil das Ergebnis nur zwei Zustände kennt, lässt es sich nicht animieren. Ein Farbwechsel im Hover überblendet den Hintergrund weich und schaltet die Schrift hart um:
.btn--marke {
background-color: #fff;
color: contrast-color(#fff); /* schwarz */
transition: background-color 0.6s ease, color 0.6s ease;
}
.btn--marke:hover {
background-color: #111;
color: contrast-color(#111); /* weiß – springt, statt zu blenden */
}
Der Sprung passiert außerdem nicht auf halber Strecke, sondern bei etwa 18 Prozent Relativhelligkeit – dort, wo Schwarz und Weiß rechnerisch gleichziehen. Beim Verlauf von Weiß nach Schwarz kippt die Schrift also erst kurz vor Schluss, was deutlich unruhiger wirkt als ein Umschalten in der Mitte. Für animierte Zustände setzt du die beiden Schriftfarben besser weiterhin von Hand.
Kombinieren mit oklch() und color-mix()
Reines Schwarz auf einer Markenfarbe wirkt schnell hart. Der Ausweg ist, das Ergebnis
von contrast-color() nicht als Endstation zu behandeln, sondern als
Ausgangspunkt. Mit der relativen Farbsyntax lässt sich der Farbton des Hintergrunds in
die Schriftfarbe zurückspiegeln:
.karte {
--ton: 262; /* Farbwinkel der Marke */
--flaeche: oklch(0.62 0.14 var(--ton));
background: var(--flaeche);
/* Helligkeit aus contrast-color(), Farbton aus der Marke:
ergibt ein sehr dunkles Indigo statt reinem Schwarz –
oder ein blasses Eisblau statt reinem Weiß. */
color: oklch(from contrast-color(var(--flaeche)) l 0.04 var(--ton));
}
Der zweite nützliche Partner ist color-mix(). Statt den Kontrast
einzufärben, nimmst du ihm kontrolliert etwas Schärfe – ideal für Ränder,
Platzhaltertexte und alles, was bewusst zurücktreten soll:
.hinweis {
--flaeche: var(--status-farbe);
background: var(--flaeche);
color: color-mix(in oklch, contrast-color(var(--flaeche)) 85%, var(--flaeche));
border: 1px solid
color-mix(in oklch, contrast-color(var(--flaeche)) 35%, var(--flaeche));
}
.hinweis input::placeholder {
color: color-mix(in oklch, contrast-color(var(--flaeche)) 55%, var(--flaeche));
}
Für hellen und dunklen Modus spielt die Funktion ohnehin von selbst mit, weil sie
den berechneten Wert ihres Arguments auswertet – ein light-dark()
darin reicht völlig:
:root {
color-scheme: light dark;
--flaeche: light-dark(#ffffff, #121212);
}
.panel {
background: var(--flaeche);
color: contrast-color(var(--flaeche)); /* folgt dem Modus automatisch */
}
Sauber einbauen
„Baseline: newly available“ heißt, dass ein spürbarer Teil der Besucher die Funktion noch nicht hat. Der Einbau gehört deshalb hinter eine Abfrage – und zwar so, dass der Fallback der Normalfall ist und die Funktion die Verbesserung:
/* Basis: eine bewusst gewählte Schriftfarbe, die auf der gesamten
zugelassenen Farbspanne funktioniert. */
.badge--marke {
background: var(--marke);
color: var(--marke-text, #fff);
}
/* Verbesserung: der Browser rechnet selbst. */
@supports (color: contrast-color(#000)) {
.badge--marke {
color: contrast-color(var(--marke));
}
}
Der entscheidende Punkt steckt in der ersten Regel: --marke-text ist
weiterhin ein Feld, das jemand pflegen muss – aber nur noch eines, und nur noch als
Rückfallebene. Wer die Farbwahl im Backend anbietet, kann den Wert dort einmal
vorbelegen, statt ihn für jede Komponente einzeln zu hinterlegen.
Was dabei nebenbei verschwindet, ist der ganze Apparat drumherum: die Farbbibliothek im Bundle, der Hydration-Flash beim ersten Rendern, die Logik, die bei jedem Theme-Wechsel erneut über alle Elemente läuft. Ein Stylesheet, das den Kontrast selbst bestimmt, hat schlicht weniger bewegliche Teile – und das ist der eigentliche Gewinn, nicht die eingesparten Kilobytes.
Fazit
contrast-color() löst kein Designproblem, sondern ein Problem der
Zuständigkeit: Die Textfarbe wird dort entschieden, wo die Hintergrundfarbe tatsächlich
bekannt ist – im Browser, zur Laufzeit, für jeden Wert, den ein Kunde jemals eintragen
wird. Was du weiterhin selbst prüfst, sind die Ränder deiner Palette: die Töne im
mittleren Helligkeitsbereich, animierte Zustände und alles, was auf einem Verlauf oder
Bild liegt.
Für alles andere gilt: eine Zeile CSS statt einer Abhängigkeit, die mitgeliefert, aktualisiert und irgendwann ersetzt werden muss.