This article explores four different techniques for toggling complex layout changes to a shadow DOM, using one or more pseudo-boolean CSS custom properties.

Over the last few months I’ve been progressing my AppKit project. It’s toolbox I use for quickly putting togther web apps. Among other things, AppKit provides a collection of web components whose internal layout and behaviour can be controlled from a host application using CSS.

For example, AppKit’s <ui-button> can render a text label alongside an optional icon. Two CSS custom properties holding pseudo-boolean values are used to determine whether just the icon, text label, or both are displayed. These custom properties can be set using media or container queries, allowing me to change the button layout for different viewports. A common pattern I use is to progressively replace text labels with icons as the viewport reduces in size, saving valuable space on mobile devices. I use this concept throughout AppKit to control, among other things, splitter orientations, property list formats and toolbar placements.

I came up with four different ways to acheive this. Using the CSS calc() function, @container style queries, coalescing values with @property and using animation @keyframes. This article describes each method in detail, along with it’s pros and cons.

Test preparation

Sticking with the button concept, for the purpose of this article, let’s consider a theoretical component that can render slotted text content alongside an optional icon. Something like this:

<ui-button icon="info">About this app</ui-button>

Let’s also assume that the shadow DOM for our component looks like this:

<button>
  <svg class="icon"><!-- svg content ---></svg>
  <span class="label"><slot /></span>
</button>

Finally, the markup for our application, which uses our component:

<div class="toolbox">
  <div class="toolbar">
    <ui-button icon="about">About</ui-button>
  </div>
  <div class="toolbar">
    <ui-button icon="about">Open File</ui-button>
    <ui-button icon="about">Open URL</ui-button>
    <ui-button icon="about">Save</ui-button>
  </div>
  <div class="toolbar">
    <ui-button icon="about">Tool 1</ui-button>
    <ui-button icon="about">Tool 2</ui-button>
  </div>
</div>

Here’s a preview of that with some basic styling. Use the buttons below the preview to switch between viewport sizes and see how the content renders.

The basic application without boolean custom properties

Notice that the content overflows when viewed using the mobile viewport? Let’s add the CSS custom properties to our web component so the host application can control the internal layout of these buttons.

We’re going to toggle the button label, icon and colour scheme using the following CSS custom properties. The [some-value] placeholder value will vary depending on the technique we’re using.

ui-button {
  --icon-visible: [some-value];     /* Toggle the icon on/off */
  --label-visible: [some-value];    /* Toggle the label on/off */
  --invert: [some-value];           /* Toggle to invert the button colours */
} 

1) Toggling properties with calc()

The idea here is to multiply the desired value of a CSS property (for example, font-size of 14px) by either 0 or 1. Since this technique uses calc() it will only work with numeric values, but that’s not as limiting as it may seem.

We can use numeric values to toggle between colours with color-mix, swap images with background-position, toggle visibility using scale and remove text from the document flow with a zero font-size.

Using some of these tricks, let’s add some CSS to the web component so the host application can control the layout of the button:

button {
  display: inline-flex;
  gap: calc(1ch * var(--icon-visible, 1) * var(--label-visible, 1));
  background: color-mix(in srgb, #d44, #0000 calc(100% * var(--invert, 0)));
  color: color-mix(in srgb, #fff, #d44 calc(100% * var(--invert, 0)));
}
.icon {
  width: calc(16px * var(--icon-visible, 1));
}
.label {
  font-size: calc(1em * var(--label-visible, 1));
}

A quick explainer for that. The --icon-visible, --label-visible and --invert custom properties are set by the host application and should be either 1 (on) or 0 (off). When these custom properties are used in calc() functions, we get:

  • An icon width of either 16px or 0.
  • A label font size of 1em or 0.
  • A flex gap of 1ch (if both --icon-visible and --label-visible are 1), or 0.
  • A contrasting background/foreground color of red and white.

With these style rules added to our web component, the host application can now control the appearance of the button by toggling the custom properties between 1 and 0.

.toolbar:first-child {
  --label-visible: 0;       /* disable labels - icon only */
}
.toolbar:last-child {
  --icon-visible: 0;        /* disable icon - labels only */
  --invert: 1;              /* invert colours so we have red text on white */
}

/* For a small viewport... */
@media (max-width: 35em) {
  ui-button {
    --label-visible: 0;     /* disable ALL labels */
    --icon-visible: 1;      /* enable ALL labels */
  }
}

Below is a preview of this technique in action. Notice how the buttons now collapse down to their icons when viewed on a mobile viewport?

Controlling button layout using CSS calc.

Summary

This technique has the best browser support, however it isn’t the most intuitive. Often, you’ll find yourself having to get creative with CSS property choices to acheive the desired result. Also, because this is all number based, some things are impossible to achieve, such as setting display or position.

2) Toggling with @container style queries

Container style queries allow us to define a set of style rules that will only be applied if the query condition resolves to true, as the following CSS demonstrates:

@container style(--icon-visible: no) {
  .icon {
    display: none;
  }
}

@container style(--label-visible: no) {
  .label {
    position: absolute;
    clip: rect(0 0 0 0);
  }
}

@container style(--invert: yes) {
  button {
    color: #d44;
    background: #fff;
  }
}

Since we’re no longer relying on calc, it’s possible to toggle non-numeric properties. This means we can now use a more robust “clip and absolutely position” solution for removing the label from document flow.

We’re also no longer limited to using 1 and 0 in our host application. We can be more expressive and use yes and no, or true and false.

.toolbar:first-child {
  --label-visible: no;     /* disable labels - icon only */
}
.toolbar:last-child {
  --icon-visible: no;       /* disable icon - labels only */
  --invert: yes;            /* invert colours so we have red text on white */
}

/* For a small viewport... */
@media (max-width: 35em) {
  ui-button {
    --label-visible: no;    /* disable ALL labels */
    --icon-visible: yes;    /* enable ALL labels */
  }
}

Here’s the result in all it’s glory, if your browser supports it.

Controlling button layout using style queries.

Summary

For me, this method is far better than the calc approach. It’s much easier to understand, code is cleaner, and you're not limited to working with numeric properties. We could even go a step further and swap the boolean concept used for icons/text and go with something like more "enum-like", such as layout: [icon|text|both].

Container style queries is a powerful feature but, at the time of writing, support isn’t great (just Chromimum). Hopefully, this feature will get more traction with other browser vendors in the near future.

3) Using @property coalescing

This method uses the CSS property at-rule to convert an invalid CSS property value into something valid. It works by defining --icon-visible, --label-visible, and --invert as new properties that only accept yes and no values. We also ensure that the initial-value is set to one of these values:

@property --icon-visible {
  syntax: "yes | no";
  inherits: true;
  initial-value: yes;
}

Next, we then define a second property that accepts either yes or no AND the CSS value we wish to use with a style rule:

@property --label-clip {
  syntax: "yes | &lt;number>+";
  inherits: true;
  initial-value: 0 0 0 0;
}

.label {
  --label-clip: var(--label-visible);
  clip-path: rect(var(--label-clip));
}

In this example, the --label-clip custom property can be either the literal value yes, or a list of space-delimited numbers (<number>+) and it’s initial value is 0 0 0 0. This property will be set to the value of --label-visible and the resulting value will be used to set the clip-path property.

When --label-visible is yes:

  1. --label-clip resolves to yes, which is a valid value.
  2. clip-path resolves to rect(yes), which is invalid.
  3. clip-path will be ingored so the content will render as normal.

When --label-visible is no:

  1. --label-clip resolves to the initial value of 0 0 0 0 because no is invalid.
  2. clip-path resolves to rect(0 0 0 0), which is valid.
  3. clip-path will be applied, hiding the content.

We can do the same thing for the position and display properties:

@property --label-clip {
  syntax: "yes | &lt;number>+";
  inherits: true;
  initial-value: 0 0 0 0;
}

@property --label-position {
  syntax: "yes | absolute";
  inherits: true;
  initial-value: absolute;
}

@property --icon-display {
  syntax: "yes | none";
  inherits: true;
  initial-value: none;
}

.icon {
  display: var(--icon-display);
}

.label {
  --label-clip: var(--label-visible);
  --label-position: var(--label-visible);
  clip-path: rect(var(--label-clip));
  position: var(--label-position);
}

That’s a lot of extra CSS!

It’s at this point I decided not to pursue this one any further. It works, but there are far too many custom property definitions for my liking, and we haven’t even considered the colour rules for --invert yet, which would add another four definitions.

For the sake of completeness, here’s the above in action:

Controlling button layout using @property value coalescing.

Summary

Too many property definitions are required to make this work. It’s not as simple as container styles and isn’t really any easier to understand than the calc() approach. Using @property to coalesce one value to another is interesting idea and may have uses elsewhere but, for this purpose it’s not a practical solution.

4) Using @keyframe and animations

This technique defines the style rules for an element’s on and off states using @keyframes. The styles for each state are set using the from and to keyframes, with the from reflecting off and the to state reflecting on.

The following CSS is used to toggle the text label on and off. Note we don’t define a to keyframe here. This is because we want the browser to use the cascade to resolve the "on" state.

@keyframes label-visiblilty {
  /** off frame */
  from {
    position: absolute;
    clip: rect(0,0,0,0);
  }
}

.label {
  animation-name: label-visiblilty;
  animation-duration 1s;
  animation-delay: calc(var(--label-visible, 1) * -1s);
  animation-timing-function: steps(2);
  animation-play-state: paused;
  animation-fill-mode: forwards;

  /* or, the shorthand: */
  animation: label-visiblilty 1s calc(var(--label-visible, 1) * 1s) steps(2) paused forwards;
}

To hide the button label, we're defining a paused, one second animation that uses the label-visiblilty keyframes. We're not actually going to run this animation - just jump between the start and end frames — so the duration value doesn’t matter, just as long as it’s not zero.

To cover off the case where --label-visible is not equal to 1 or 0, interpolation is disabled by setting the timing function to steps(2) . The animation is also set to fill forwards, so the end keyframe (to) styles are applied to the default state.

Finally, animation-delay is used to apply the styles from either the first or last keyframe. We're using calc to convert the pseduo-boolean 1 or 0 from our custom property to a time unit. If --label-visible isn’t defined then we use 1 as a default.

When --label-visible is 1:

  • animation-delay resolves to -1s, which causes the browser to apply the styles defined in the to keyframe.

When --label-visible is 0:

  • animation-delay resolves to 0s, which causes the browser to apply the styles defined in the from keyframe.

Here’s that in action:

Controlling button layout using @keyframes and animations.

Take away

Right now, the calc approach is the best solution if you want compatibility — and that’s what I opted for in AppKit. That said, my favoured solution is @container style(). Let’s keep our fingers crossed for better browser support in the future.

Personal Achievements

  • 2017 Web Designer Magazine: CSS VR interview
  • 2015 JS1k – winner
  • 2014 Net Awards: Demo of the year – winner
  • 2014 Net Awards: Developer of the year – longlist
  • 2013 Public speaking for the first time
  • 2011 .net Magazine innovation of the year – shortlist

Referenced in…

Smashing CSS, CSS3 for web designers, Programming 3D Applications with HTML5 and WebGL and more.

My work is referenced in a number of industry publications, including books and magazines.