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:
Let’s also assume that the shadow DOM for our component looks like this:
Finally, the markup for our application, which uses our component:
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.
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.
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:
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
or0
. - A label font size of
1em
or0
. - A flex gap of
1ch
(if both--icon-visible
and--label-visible
are1
), or0
. - 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
.
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?
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:
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
.
Here’s the result in all it’s glory, if your browser supports it.
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:
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:
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
:
--label-clip
resolves toyes
, which is a valid value.clip-path
resolves torect(yes)
, which is invalid.clip-path
will be ingored so the content will render as normal.
When --label-visible
is no
:
--label-clip
resolves to the initial value of0 0 0 0
becauseno
is invalid.clip-path
resolves torect(0 0 0 0)
, which is valid.clip-path
will be applied, hiding the content.
We can do the same thing for the position
and display
properties:
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:
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.
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 theto
keyframe.
When --label-visible
is 0
:
animation-delay
resolves to0s
, which causes the browser to apply the styles defined in thefrom
keyframe.
Here’s that in action:
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.