This article documents a CSS-only fallback for conditionally controlling layout in browsers where container style queries are not supported.
I came across this this technique while creating a splitview Web Component for my AppKit project. The component creates two resizable content slots separated by a draggable handle. Users can drag the handle to expand or contract the content in each slot. A key feature I want to support is configurable orientation, allowing the split view to be resized either horizontally or vertically.
This could be handled using a boolean HTML attribute, which would allow elements inside the shadow DOM to be styled using CSS attribute selectors. However, I want the component’s layout to be managed entirely through CSS — alongside the rest of the responsive styling — rather than relying on toggling HTML attributes to control layout.
CSS container style queries are perfect for this, as they allow components to adapt their styles based on their container’s properties. Unfortunately, browser support for container style queries is still limited, so I started looking for a CSS-only alternative to bridge the gap — and that’s where CSS animations come in.
Before we get into the details, let’s look at a basic component and see how things should work with container style queries.
The basic component
Here’s how the splitview component could be implemented in a host application:
<my-splitview>
<div slot="start">I am in the start slot</div>
<div slot="end">I am in the end slot</div>
</my-splitview>
The shadow DOM of <my-splitview>
looks like this:
<div class="splitView">
<div class="splitView__pane">
<slot name="start"></slot>
</div>
<div class="splitView__handle"></div>
<div class="splitView__pane">
<slot name="end"></slot>
</div>
</div>
And the bare-bones CSS:
.splitView {
display: flex;
height: 100%;
width: 100%;
}
.splitView__handle {
flex: 0 0 var(--split-width); /* Split handle width */
cursor: ew-resize; /* Set the cursor to indicate drag direction */
}
.splitView__pane:first-child {
flex: 0 0 var(--split-position); /* Split handle position */
}
.splitView__pane:last-child {
flex: 1; /* Fill the remaining space */
}
Here’s an example of that:
Note: I won’t be covering the actual split logic in this article, since the focus is on the CSS. So, for demonstration purposes, the split position in previews is controlled with a CSS animation.
To switch to a vertical layout, we just need to update a couple of style rules — changing the flex-direction
to stack the panes vertically, and setting the cursor
to show that the handle now drags up and down. To make the change clearer in the previews, I’m also updating the handle color, but these styles aren’t included in the example code.
.splitView {
flex-direction: column;
}
.splitView__handle {
cursor: ns-resize;
}
Using CSS to toggle between layouts
Now that we have the structure in place, we can configure the component to change its layout using a container style. The goal is to control the split view’s orientation with a custom property, like this:
my-splitview {
--orientation: vertical; /* Vertical for by default */
}
@media (min-width: 40em) {
my-splitview {
--orientation: horizontal; /* Switch to horizontal for wider viewports */
}
}
or this:
<my-splitview style="--orientation: vertical">
<!-- contents omitted -->
</my-splitview>
The first step is to define a new --orientation
custom property and make sure it only accepts either vertical
or horizontal
, defaulting to the latter if an invalid value is used. We also set it to inherit
so it can be passed down into the shadow DOM. Strictly speaking, registering the property isn’t necessary, but it helps prevent mistakes by enforcing valid values.
I’ve chosen to register the property using the JavaScript API from inside my Web Component. This way, it only gets registered when the component is used. You could define it in a stylesheet if you prefer.
CSS.registerProperty({
name: '--orientation',
syntax: 'vertical | horizontal',
initialValue: 'horizontal',
inherits: true
});
Now we can use a container style query to modify styles across multiple elements based on the value of the --orientation
custom property. When the value is vertical
, the vertical styles from earlier can be applied.
@container style(--orientation: vertical) {
.splitView {
flex-direction: column;
}
.splitView__handle {
cursor: ns-resize;
}
}
And that’s it — the split view layout can now be controlled by setting --orientation: vertical
or --orientation: horizontal
— assuming your browser supports container style queries.
Try using the device buttons in the following preview to see the split view changes orientation as the viewport changes size.
But what if the browser doesn’t support container style queries? The component will only ever work in it’s default horizontal orientation — is there a way to acheive the same behaviour without using style queries...?
Yes — using CSS animations.
Using keyframes to store element styles
We can treat the @keyframes
at-rule like a key/value store, where we define style rules for each element we want to modify. In this example, we can define two sets of keyframes named vertical
and horizontal
— matching the allowed values in our custom property. Since the default layout is horizontal, we only need the vertical keyframes to override those styles when needed.
Once the keyframes are defined, we add a CSS animation to the elements we want to style conditionally, using var(--orientation)
as the animation-name
. By pausing the animation and applying a negative animation-delay
, we can apply the styles from a specific keyframe without actually animating anything.
For example, here are the style changes for the vertical layout, grouped into keyframes. The 0% rules are for .splitView
, and the 100% rules target .splitView__handle
:
@keyframes vertical {
/* Styles for .splitView */
0% {
flex-direction: column;
}
/* Styles for .splitView__handle */
100% {
cursor: ns-resize;
}
}
Applying the styles is now a case of adding an animation to the the relevant elements with the appropriate animation-delay
:
.splitView {
animation: 1s 0s var(--orientation) steps(2) forwards paused; /* Use keyframe 0% */
}
.splitView__handle {
animation: 1s -1s var(--orientation) steps(2) forwards paused; /* Use keyframe 100% */
}
Use the device buttons in the preview to see the splitview switch orientation as the viewport size changes.
Adding extra element styles
Let’s add a third styled element by adding a grip to the split handle with a pseudo-element.
.splitView__handle::before {
content: "︙";
}
@container style(--orientation: vertical) {
.splitView {
flex-direction: column;
}
.splitView__handle {
cursor: ns-resize;
}
.splitView__handle::before {
rotate: 0.25turn; /* New rule to rotate the handle grip */
}
}
If your browser supports container style queries you should be able to see that working here:
Let’s make the same change to the animation fallback. We’ll need a total of three keyframe selectors — one extra to cover our new element:
@keyframes vertical {
/* Styles for `.splitView` */
0% {
flex-direction: column;
}
/* Styles for `.splitView__handle` */
50% {
cursor: ns-resize;
}
/* Styles for `.splitView__handle::before` */
100% {
rotate: 0.25turn;
}
}
Now we need to update the existing animations to account for the extra keyframe. Our animation now contains three rulesets (steps(3)
) and the .splitView__handle
styles are now set at the 50% keyframe, so the animation-delay
for that element needs to start at -0.5s
:
.splitView {
animation: 1s 0s var(--orientation) steps(3) forwards paused; /* Use keyframe 0% */
}
.splitView__handle {
animation: 1s -0.5s var(--orientation) steps(3) forwards paused; /* Use keyframe 50% */
}
.splitView__handle::before {
animation: 1s -1s var(--orientation) steps(3) forwards paused; /* Use keyframe 100% */
}
But wait! There’s a problem...
The .splitView__handle
element has rotated!?
This happens because many CSS properties, including rotate
, are interpolated across keyframes. Since the .splitView__handle::before
rule is defined at the 100% keyframe, the browser starts interpolating the rotation from keyframe 0, at the beginning of the animation. As a result, at the 50% point, it’s rotated halfway. To avoid this unintended transition, we need to explicitly set the rotation to 0turn
at the 50% keyframe.
@keyframes vertical {
0% {
flex-direction: column;
}
50% {
cursor: ns-resize;
rotate: 0turn; /* Ensure `rotate` isn’t interpolated */
}
100% {
rotate: 0.25turn;
}
}
Now the rotation is fixed, the grip renders as expected and is toggled along with the other the styles when --orientation
is changed.
Best of both worlds?
It’s possible to combine both techniques — using modern container style queries where supported, and falling back to the animation-based approach when they’re not.
Ideally, we’d use @supports
to detect the @container style()
at-rule, but unfortunately, that doesn’t work as expected. Instead, the fallback animation should be applied by default, and then disabled inside the container style query block, like this:
/* Define CSS animations as a fallback
----------------------------------------------------------------------------- */
.splitView {
animation: 1s 0s var(--orientation) steps(3) forwards paused;
}
.splitView__handle {
animation: 1s -.5s var(--orientation) steps(3) forwards paused;
}
.splitView__handle::before {
animation: 1s -1s var(--orientation) steps(3) forwards paused;
}
@keyframes vertical {
0% {
flex-direction: column;
}
50% {
cursor: ns-resize;
rotate: 0turn;
}
100% {
rotate: 0.25turn;
}
}
/* Use container styles if supported - disable the animations
----------------------------------------------------------------------------- */
@container style(--orientation: vertical) {
.splitView {
animation: none;
flex-direction: column;
}
.splitView__handle {
animation: none;
cursor: ns-resize;
}
.splitView__handle::before {
animation: none;
rotate: 0.25turn;
}
}
Here’s the example working cross browser: