Practical Refactors with Modern CSS Colors

Theming

Wait…
don’t we already have theming?

:root {
  --surface-primary: #eee;
  --surface-secondary: #ccc;
  --text-primary: #111;
  --text-secondary: #333;
  /* expand to >1K LOC… */
}
:root {
  color-scheme: light dark;
  /* vars: light values (>1K LOC) */
}
@media (prefers-color-scheme: dark) {
  :root {
    --surface-primary: #111;
    --surface-secondary: #333;
    --text-primary: #eee;
    --text-secondary: #ccc;
    /* expand to >1K LOC… */
  }
}
:root {
  color-scheme: light dark;
  /* vars: light values (>1K LOC) */
}
@media (prefers-color-scheme: dark) {
  :root {
    /* vars: dark values (>1K LOC) */
  }
}
[data-theme="dark"] {
  color-scheme: dark; /* lock UA styles to dark */
  /* vars: dark values (>1K LOC repeated!) */
}
:root,
[data-theme="light"] {
  color-scheme: light dark;
  /* vars: light values (>1K LOC) */
}
[data-theme="light"] {
  color-scheme: light; /* lock UA styles to light */
}
@media (prefers-color-scheme: dark) {
  :root {
    /* vars: dark values (>1K LOC) */
  }
}
[data-theme="dark"] {
  color-scheme: dark; /* lock UA styles to dark */
  /* vars: dark values (>1K LOC repeated!) */
}

light-dark()

The new light-dark() function lets you define both values in one line:

light-dark(<lightValue>, <darkValue>)

  • Values must be colors:
    • no strings, numbers, keywords, etc
  • Firefox supports the next version:
    • url(), gradients
:root {
  color-scheme: light dark;

  --surface-primary: light-dark(#eee, #111);
  --surface-secondary: light-dark(#ccc, #333);
  --text-primary: light-dark(#111, #eee);
  --text-secondary: light-dark(#333, #ccc);
  /* expand to >1K LOC… */
}
:root {
  color-scheme: light dark;

  --surface-primary: light-dark(#eee, #111);
  --surface-secondary: light-dark(#ccc, #333);
  --text-primary: light-dark(#111, #eee);
  --text-secondary: light-dark(#333, #ccc);
  /* expand to >1K LOC… */
}
[data-theme="light"] {
  color-scheme: light; /* forces <lightValue> */
}
[data-theme="dark"] {
  color-scheme: dark; /* forces <darkValue> */
}

CanIUse Baseline 2024, supported in major browsers, covering 87.24% of global usage.

87%

@import "tailwindcss"; /* v4 */

@theme {
  --surface-primary: light-dark(#eee, #111);
  --surface-secondary: light-dark(#ccc, #333);
  --text-primary: light-dark(#111, #eee);
  --text-secondary: light-dark(#333, #ccc);
}
<!-- media query -->
<html class="scheme-light-dark">

<!-- override -->
<html class="scheme-light">
<html class="scheme-dark">

Color Manipulation

$red: #f00;

.button.red {
  background: $red;
}

.button.red:hover {
  background: lighten($red, 20%);
}
:root {
  --red: #f00;
}

.button.red {
  background: var(--red);
}

.button.red:hover {
  background: lighten(var(--red), 20%);
}
😢🪊

CSS Relative Colors

:root {
  --red: #f00;
}

.button.red {
  background: var(--red);
}

.button.red:hover {
  background: rgb(from var(--red) r, g, b, 0.8);
}

Existing color functions don’t need commas.

rgb(25, 25, 25)
rgb(25 25 25)

hsl(120, 50, 50)
hsl(120 50 50)

Existing color functions implicitly handle alpha.

rgb(25, 25, 25, 0.8)
rgb(25 25 25 / 0.8)

hsl(120, 50, 50, 0.4)
hsl(120 50 50 / 0.4)

Breaking from down...

rgb(
  from var(--red) r, g, b, alpha
)

normal CSS color function,
could be hsl(), oklch(), etc

extracts channel data from any color

any color or color variable,
in any format

output each color channel & alpha,
any channel can be overridden

Reminder: new syntax, identical output

rgb(
  from var(--red) r, g, b, alpha
)

rgb(
  from var(--red) r g b / alpha
)

Tints

:root {
  --surface-primary--coolest: rgb(
    from var(--surface-primary) r g 255
  );
  --surface-primary--warmer: rgb(
    from light-dark(#eee, #111) calc(r + 150) g b
  );
}

Adjust Alpha

:root {
  --surface-primary--shadow: hsl(
    from var(--surface-primary) h s l / 0.8
  );
  --surface-primary--shadow-light: rgb(
    from var(--surface-primary) r g b / 20%
  );
}

Normalized Lightness

:root {
  --surface-primary--dark: hsl(
    from var(--surface-primary) h s 20
  );
  --surface-primary--lighten: hsl(
    from var(--surface-primary) h s calc(l + 15)
  );
}
:root {
  --red: #f00;
  --red--100: hsl(from var(--red) h s 90);
  --red--200: hsl(from var(--red) h s 80);
  --red--300: hsl(from var(--red) h s 70);
  --red--400: hsl(from var(--red) h s 60);
  --red--500: hsl(from var(--red) h s 50);
  --red--600: hsl(from var(--red) h s 40);
  --red--700: hsl(from var(--red) h s 30);
  --red--800: hsl(from var(--red) h s 20);
  --red--900: hsl(from var(--red) h s 10);
}
:root {
  --start: dodgerblue;

  --primary: hsl(
    from var(--start) h s l
  );
  --secondary: hsl(
    from var(--start) calc(h + 120) 67 33
  );
  --tertiary: hsl(
    from var(--start) calc(h + 240) 67 33
  );
}
@mixin button($color: $blue) {
  color: color.scale($color, $lightness: -15%);
  border-color: $color;
  background: color.change($color, $lightness: 90%);

  &:hover {
    color: color.scale($color, $lightness: -25%);
    border-color: color.scale($color, $lightness: -15%);
    background: color.change($color, $lightness: 80%);
  }

  /* other interactive states, etc */
}
.button.red {
  @include button($red);
}

.button.green {
  @include button($green);
}

.button.orange {
  @include button($orange);
}
.button {
  --button-color: var(--blue);

  color: hsl(from var(--button-color) h s calc(l - 15));
  border-color: var(--button-color);
  background: hsl(from var(--button-color) h s 90);
}
.button:hover {
  color: hsl(from var(--button-color) h s calc(l - 25));
  border-color: hsl(
    from var(--button-color) h s calc(l - 15)
  );
  background: hsl(from var(--button-color) h s 80);
}
.button.green {
  --button-color: var(--green);
}

.button.red {
  --button-color: var(--red);
}

.button.orange {
  --button-color: var(--orange);
}

CanIUse Baseline 2024, supported in major browsers, covering 89.26% of global usage.

89%

CSS color-mix()

.button {
  background: color-mix(
    in hsl,
    var(--button-color) 80%,
    var(--shadow-tint)
  );
}

Breaking it down...

color-mix(
  in hsl,
  var(--button-color) 80%,
  var(--shadow-tint)
);

mix 2 colors together,
like Sass mix()

a color space:
srgb, hsl, oklch, etc

the first color,
optional percentage

the second color,
optional percentage

CanIUse Baseline 2024, supported in major browsers, covering 91.53% of global usage.

91%

Automatic Contrast

CSS contrast-color()

button {
  background: var(--button-color);
  color: contrast-color(
    var(--button-color)
  );
}

Photo by Birger Strahl on Unsplash

Photo by Birger Strahl on Unsplash

Not everything is black & white

color-mix()

button {
  background: var(--button-color);
  color: color-mix(
    in hsl,
    contrast-color(
      var(--button-color)
    ) 90%,
    var(--button-color) 10%
  );
}

CanIUse Baseline 2024, supported in major browsers, covering 67.46% of global usage.

67%

Learn More…

light-dark()

Relative Colors & color-mix()

contrast-color()

Thanks, Y’all!

James Steinbach

Senior Front-End Engineer at Delinea

jdsteinbach

Bluesky | Github | Blog