web-components-plus-css-custom-properties.txt

Tutorial

Web Components + CSS Custom Properties = ♥

Using CSS custom properties with vanilla web components is a simple and effective way to enable users with fine-tuned control over presentational aspects of your website.

This tutorial assumes you are familiar with how to set up a web component and implement the handleEvent method for event listeners.

Try the working demo to right →

Try the working demo ↓

Setup

If you haven’t visited this page directly, I’m sure you’ve noticed that the site’s colour scheme has changed somewhat dramatically. This was easily done by appending a CSS file for to the page that overwrites the --theme-primary and --theme-secondary custom properties declared in main.css.

:root {
  --theme-primary-hue: 0deg;
  --theme-primary-saturation: 0%;
  --theme-primary-lightness: 0%;
  --theme-secondary-hue: 0deg;
  --theme-secondary-saturation: 100%;
  --theme-secondary-lightness: 100%;
}

body {
  --theme-primary: hsl(
    var(--theme-primary-hue, 0deg),
    var(--theme-primary-saturation, 0%),
    var(--theme-primary-lightness, 0%)
  );
  --theme-secondary: hsl(
    var(--theme-secondary-hue, 0deg),
    var(--theme-secondary-saturation, 0%),
    var(--theme-secondary-lightness, 100%)
  );
}

Since these are Cascading StyleSheets I am overriding the theme props a node lower at the body element and added some new granular properties for the theme’s primary and secondary HSL colour values, which allows us to change each value individually.

The HTML is pretty simple, just my hsl-slider component in a fieldset. The hsl-slider component has a single color attribute, which is just used to compose the lookup key for the CSS values above.

<fieldset>
	<legend>Primary Theme Colour</legend>
	<hsl-slider color="primary"></hsl-slider>
</fieldset>

Adding Interactivity

This is where CSS properties in tandem with a web components really shine. First I’ll create const constant values for each slider I want to render, which is just an object containing the name and the max value of the slider (have to differentiate the max hue value from the others).

const colorValues = [
  { name: "hue", max: 360 },
  { name: "saturation", max: 100 },
  { name: "lightness", max: 100 },
];

Note that I am not explicitly setting values here, since I am opting to get the current values from the CSS instead. Alternatively, I could assign the default values as attributes on my component if I wanted to compose the colour scheme via HTML instead.

In the component constructor I can iterate over these objects and look up the corresponding CSS property values from the :root selector. I am assuming that the selector is declared as --theme-{colour}-{name}, e.g. --theme-primary-h.

const color = this.getAttribute("color");
const style = getComputedStyle(document.documentElement);

if (!color) {
	return;
}

colorValues.forEach((cv) => {
	const propName = `--theme-${color}-${cv.name}`;
	const cssProp = style.getPropertyValue(propName);
	// Basic sanitation to coerce the value into a number
	const cssPropValue = parseInt(cssProp.trim());
	// Values can either be in % or deg units, so I'll infer by the max
	const isDegreeUnit = cv.max > 100;
	const unit = isDegreeUnit ? "deg" : "%";

	// Render logic next
});

Since I could adapt this component to render any colour scheme in the future, I’ll render the controls programatically.

// Render logic
// First creating all the DOM nodes
const labelEl = document.createElement("label");
// This is a UI-specific component to this site, ignore for this tutorial
const osRangeEl = document.createElement("os-range");
const inputEl = document.createElement("input");
const amountEl = document.createElement("div");

// Setting up some important values on the range slider
inputEl.type = "range";
inputEl.setAttribute("name", cv.name);
inputEl.setAttribute("data-property", propName);
inputEl.setAttribute("data-unit", unit);
inputEl.setAttribute("max", cv.max ?? 100);
// Simple validation, then assign the default value from my CSS property lookup
if (!isNaN(cssPropValue)) {
	inputEl.value = cssPropValue;
}
osRangeEl.appendChild(inputEl);
amountEl.textContent = `${cssPropValue}${unit}`;

labelEl.textContent = cv.name.slice(0, 1);
labelEl.appendChild(osRangeEl);
labelEl.appendChild(amountEl);

this.appendChild(labelEl);

This renders me 3 input[type=range] sliders, each value set to the CSS custom property value from my stylesheet.

The event handler simply gets the unit and property, which has been stored as --theme-primary-h and updates the style on the :root. Additionally I’ve updated the text so users can see the exact value they’ve selected.

handleChange(event) {
	const unit = event.target.getAttribute("data-unit");
	const key = event.target.getAttribute("data-property");
	const value = event.target.value + unit;

	event.target.parentElement.nextSibling.textContent = value;
	document.documentElement.style.setProperty(key, value);
}

Finished Code

const colorValues = [
  { name: "hue", max: 360 },
  { name: "saturation", max: 100 },
  { name: "lightness", max: 100 },
];

class HslSlider extends HTMLElement {
  constructor() {
    super();
    const color = this.getAttribute("color");
    const style = getComputedStyle(document.documentElement);

    if (!color) {
      return;
    }

    colorValues.forEach((cv) => {
      const propName = `--theme-${color}-${cv.name}`;
      const cssProp = style.getPropertyValue(propName);
      // Basic sanitation to coerce the value into a number
      const cssPropValue = parseInt(cssProp.trim());
      // Values can either be in % or deg units, so I'll infer by the max
      const isDegreeUnit = cv.max > 100;
      const unit = isDegreeUnit ? "deg" : "%";

      // Render logic
      const labelEl = document.createElement("label");
      const osRangeEl = document.createElement("os-range");
      const inputEl = document.createElement("input");
			const amountEl = document.createElement("div");
      inputEl.type = "range";
      inputEl.setAttribute("name", cv.name);
      inputEl.setAttribute("data-property", propName);
      inputEl.setAttribute("data-unit", unit);
      inputEl.setAttribute("max", cv.max ?? 100);
      // Assign the default value
      if (!isNaN(cssPropValue)) {
        inputEl.value = cssPropValue;
      }
      osRangeEl.appendChild(inputEl);
      amountEl.textContent = `${cssPropValue}${unit}`;
      
      labelEl.textContent = cv.name.slice(0, 1);
      labelEl.appendChild(osRangeEl);
      labelEl.appendChild(amountEl);

      this.appendChild(labelEl);
    });

    this.addEventListener("input", this);
	}

	handleEvent(event) {
		if (event.type === "input") {
			this.handleChange(event);
		}
	}

	handleChange(event) {
		const unit = event.target.getAttribute("data-unit");
		const key = event.target.getAttribute("data-property");
		const value = event.target.value + unit;
	  
		event.target.parentElement.nextSibling.textContent = value;
		document.documentElement.style.setProperty(key, value);
	}
}

customElements.define("hsl-slider", HslSlider);

Next Steps

Now I have a simple HSL slider component that can modify my site’s theme in realtime with just 65 lines of JS and a few CSS custom properties. This is a great first draft, but it could be expanded to include a few more controls like:

  • reset all values to default
  • additional colour schemes like HEX, RGBA, HWB or LCH
  • cache overrides in local or session storage

Admittedly, this is also a more “framework” approach to writing components, where the component is represented as a single DOM node that internally declares the markup for you, so if JavaScript is disabled the UI won’t render at all. Next iteration I can demonstrate how to approach this same feature in a modern, web component way. I’ll definitely be incorporating this into my little control-panel component.

969 Words

Published

Example

Adjust the sliders to change the website theme

Primary Colour
Secondary Colour