Flexible CSS Colors With Custom Properties

Let’s use CSS Custom Properties and calc() to create various CSS color effects, fasten your seatbelt, we’re going from 0 to 100 in 1.5 seconds.

No time to waste, let’s go!

Setting Up A Basic Color Scheme

First we create a basic black text on white background style for our page.

Note that in the :root selector we don’t use functional notations for our colors, we only set the numbers.

<html>
    <p>Our very plain page</p>
</html>
:root {
    --foreground: 0, 0, 0; /* black */
    --background: 255, 255, 255; /* white */
}

html {
    color: rgb(var(--foreground));
    background: rgb(var(--background));
}

Our very plain page

A very plain page indeed, let’s add some different tints of grey.

Creating Different Color Tints

Let’s add a paragraph with a grey background. We can do this by creating an rgba color and lowering its alpha value.

<html>
    <p>Our very plain page</p>
    <p class="panel">A panel with a grey background</p>
</html>
.panel {
    background: rgba(var(--foreground), 0.25);
}

Our very plain page

A panel with a grey background

By adjusting the alpha value we can create various tints of grey.

<html>
    <p>Our very plain page</p>
    <p class="panel">A panel with a grey background</p>
    <p class="panel-dark">A panel with a dark grey background</p>
</html>
.panel-dark {
    background: rgba(var(--foreground), 0.5);
}

Our very plain page

A panel with a grey background

A panel with a dark grey background

A useful trick, but in situations where our panels overlap it will quickly become apparent that their backgrounds aren’t opaque.

Let’s move the bottom panel so it overlaps with the top one.

.panel-dark {
    margin-top: -1em;
}

Our very plain page

A panel with a grey background

A panel with a dark grey background

We can see the dark color where the top panel shows behind the bottom panel.

To fix this we set can the background-color of the bottom panel to an opaque color and then overlay a linear-gradient on top of that. We can reduce code duplication by assigning the gradient color to a separate custom property.

.panel-dark {
    --overlay: rgba(var(--foreground), 0.5);
    background-image: linear-gradient(var(--overlay), var(--overlay));
    background-color: rgb(var(--background));
}

Our very plain page

A panel with a grey background

A panel with a dark grey background

Now our darker panel is opaque.

You’d imagine it would be possible to use multiple background colors, but while the background property supports setting multiple background images it’s not possible to stack background colors.

Adding A Dark Theme

Next up is adding a dark theme using the prefers-color-scheme: dark media feature.

We only have to swap the --foreground and --background values, and we’re set.

:root {
    --foreground: 0, 0, 0;
    --background: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
    :root {
        --foreground: 255, 255, 255;
        --background: 0, 0, 0;
    }
}

Let’s view the light and dark themes side by side.

Our very light page

A grey panel

A dark grey panel

Our very dark page

A grey panel

A dark grey panel

Great, our theme is working!

Let’s step it up a notch by adding some more exiting styles.

Using CSS Custom Properties As Style Modifiers

We’re going to replace the text on our page with a button that we’ll try to make look good in both the bright and the dark theme.

<html>
    <button type="button">Our button</button>
</html>
button {
    color: rgb(var(--background));
    background: rgb(var(--foreground));
    border: 0;
}

Our button is looking “good” and it’s automatically styled in dark mode as well.

Let’s now add some more details to our button.

We’ll try to make it look more realistic by simulating light shining from the top. This creates a shadow below the button and a highlight on top. To make this more apparent we will dim the page background color.

html {
    /* Dim page background color */
    --overlay: rgba(var(--foreground), 0.2);
    background-image: linear-gradient(var(--overlay), var(--overlay));
    background-color: rgb(var(--background));

    /* Same text color as before */
    color: rgb(var(--foreground));
}

button {
    /* Nicely rounded corners */
    border-radius: 0.5rem;

    /* Slightly dimmed color for text */
    color: rgba(var(--background), 0.85);

    /* An inset shadow above the text */
    text-shadow: 0 -1px 0 rgba(var(--foreground), 0.9);

    /* A subtle gradient as background */
    background: linear-gradient(
        rgba(var(--foreground), 0.6),
        rgba(var(--foreground), 0.75)
    );

    /* A shadow and inset, define all the values used in the box-shadow, and then stack them */
    --shadow-behind: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.4);
    --shadow-below: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.2), 0 0.06125rem 0.125rem
            rgba(0, 0, 0, 0.2);
    --highlight-outline: 0 0 0 1px rgba(0, 0, 0, 0.5);
    --highlight-inset: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
    --highlight-top: inset 0 1px 0 0 rgba(255, 255, 255, 0.3);
    --highlight-bottom: inset 0 -1px 0 rgba(0, 0, 0, 0.4);

    box-shadow: var(--highlight-outline), var(--highlight-top), var(
            --highlight-bottom
        ), var(--highlight-inset), var(--shadow-below), var(--shadow-behind);
}

Let’s take a look at the results.

The button looks excellent on the bright page, but on the dark page something it’s not great.

These problems are partly caused by blatantly inverting the colors, but it’s also related to our eyes being more sensitive details when it’s light.

Let’s introduce 2 new CSS Custom Properties and update the button styles to fix gradient directions and improve contrast so details are better visible in dark mode.

:root {
    --foreground: 0, 0, 0;
    --background: 255, 255, 255;

    /* By default this is set to 1 so it doesn't cause inversions */
    --mod-invert: 1;
}

@media (prefers-color-scheme: dark) {
    :root {
        --foreground: 255, 255, 255;
        --background: 0, 0, 0;

        /* We set this one to -1 so we can use it to invert values */
        --mod-invert: -1;

        /* Set to 2 so we can use this to multiple values to increase contrast */
        --mod-contrast: 2;
    }
}

button {
    /* Nicely rounded corners */
    border-radius: 0.5rem;

    /* Slightly dimmed color for text */
    color: rgba(var(--background), 0.85);

    /* 
    An inset shadow above the text 
    - Flip the text shadow direction 
    - Lower shadow opacity
    */
    --text-shadow-y: calc(-1px * var(--mod-invert));
    --text-shadow-alpha: calc(0.9 * (var(--mod-contrast, 4) * 0.25));
    text-shadow: 0 var(--text-shadow-y) 0 rgba(
            var(--foreground),
            var(--text-shadow-alpha)
        );

    /* 
    A subtle gradient as background
    - Flip the gradient direction in dark mode 
    - Lower opacity in dark mode
    */
    --linear-gradient-direction: calc(90deg + (var(--mod-invert) * 90deg));
    --linear-gradient-factor: calc(var(--mod-contrast, 2.5) * 0.4);
    background: linear-gradient(
        var(--linear-gradient-direction),
        rgba(var(--foreground), calc(0.6 * var(--linear-gradient-factor))),
        rgba(var(--foreground), calc(0.75 * var(--linear-gradient-factor)))
    );

    /* 
    A shadow and inset, define all the values used in the box-shadow, and then stack them
    - Calculate factor to use for the button outline intensity
    */
    --shadow-behind: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.4);
    --shadow-below: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.2), 0 0.06125rem 0.125rem
            rgba(0, 0, 0, 0.2);
    --highlight-outline-alpha-factor: calc(var(--mod-contrast, 2.85) * 0.35);
    --highlight-outline: 0 0 0 1px rgba(var(--foreground), calc(0.5 * var(--highlight-outline-alpha-factor)));
    --highlight-inset: inset 0 0 0 1px rgba(255, 255, 255, calc(0.1 * var(--mod-contrast, 1)));
    --highlight-top: inset 0 1px 0 0 rgba(255, 255, 255, calc(0.3 * var(--mod-contrast, 1)));
    --highlight-bottom: inset 0 -1px 0 rgba(0, 0, 0, calc(0.4 / var(--mod-contrast, 1)));
    box-shadow: var(--highlight-outline), var(--highlight-top), var(
            --highlight-bottom
        ), var(--highlight-inset), var(--shadow-below), var(--shadow-behind);
}

Let’s compare the old and the new dark button, the light version has stayed the same.

Excellent, now let’s look at the light and dark pages next to each other.

Quite the improvement!

Admittedly this all escalated a bit fast in this last section, but I hope you learned a couple new tricks and this gives you some ideas on how to apply them to your code, see you in the next one!

I share web dev tips on Bluesky, if you found this interesting and want to learn more, follow me thereBluesky

Or join my newsletter

More articles More articles