The Future of CSS: Scroll-Linked Animations with @scroll-timeline (Part 2)

Example Scroll-Linked Animation with Element-Based Offsets, CSS FTW! 🤩

The Scroll-linked Animations Specification is an upcoming addition to CSS that defines a way for creating animations that are linked to a scroll offset of a scroll container. Even though the specification is still in draft, and in no way finalized nor official, it already has experimental support in Chromium.

In the first part of this series we covered how to create Scroll-Linked Animations between two absolute scroll-offsets using the @scroll-timeline at-rule and animation-timeline CSS property.

In this second part we dial it up a notch and dig into creating Scroll-Linked Animations based on the location of an element within its scroller.

~

👨‍🔬 The CSS features described in this post are still experimental and not finalized at all! If you’re feeling adventurous you can play with these new features today, but you’ll need at least Chromium 89 with the #experimental-web-platform-features flag enabled through chrome://flags.

💥 To keep your primary Chrome install clean, I recommend you do not set this in Chrome Stable, but resort to Beta / Canary builds.

👀 If you don’t understand how to do this, or don’t feel safe doing this, fear not: This post also includes recordings and/or fallback versions using JavaScript for most of the demos.

💄 While the Scroll-Linked Animations Specification also describes a JavaScript interface, the main focus of this post will be its CSS counterpart. The JS alternatives won’t be covered in detail.

~

Table of Contents

  1. Scroll-Linked Animations (Part 1), a Recap
  2. Element-based Offsets?
  3. Element-based Offsets in CSS
  4. Element-based Offsets and @scroll-timeline (Revealing Image Demo)
  5. Typical from/to <element-offset> Combinations
  6. Demos
    1. Revealing Images Demo, Revisited
    2. Contact List Demo
    3. Contact List Demo, Revisited
    4. Horizontal Scroll Section Demo
    5. CoverFlow Demo
    6. Stacking Cards Demo
  7. In Closing

~

# Scroll-Linked Animations (Part 1), a Recap

In the first part of this series we took a look at @scroll-timeline and its descriptors. If I explained it all properly, the code snippet below should make sense:

@keyframes resize-progressbar {
  to {
    transform: scaleX(1);
  }
}

@scroll-timeline scroll-in-gallery {
  source: selector(#gallery__scrollcontainer);
  scroll-offsets: 0%, 100%;
  orientation: horizontal;
  time-range: 1s;
}

#gallery__progressbar {
  transform: scaleX(0.5);
  animation: 1s linear forwards resize-progressbar;
  animation-timeline: scroll-in-gallery;
}

🚨 It’s very important to understand the contents of the first part of this series as this post builds further upon that knowledge. If you haven’t read it, you most likely won’t understand all that much of this second part. You can read the first part here.

~

# Element-based Offsets?

Besides setting absolute values as scroll-offsets, the Scroll-Linked Animations Specification also allows you to set Element-based Scroll Offsets. With this type of Scroll Offsets the animation is based on the location of an element within the scroll-container.

Typically this is used to animate an element as it comes into the scrollport until it has left the scrollport; e.g. while it is intersecting:

See the Pen Scroll-Linked Animations Visualization: Element-Based Offsets by Bramus (@bramus) on CodePen.

When scrolling down in the visualization (using the ⏭ button) you’ll see the box switch colors:

  1. as it slides into the scrollport from the bottom
  2. after it has just left the scrollport at the top

🔄 When scrolling back up again you’ll see the same happening in reverse.

Both these turning points can be used as offsets for a Scroll-Linked Animations. Because they are described from the perspective of the box itself (which is an HTML Element), we call these “Element-based Offsets”.

In pseudo-code, our @scroll-timeline would look like this:

@scroll-timeline element-enters-and-leaves-the-scrollport {
  scroll-offsets:
    “the box is positioned underneath the bottom edge of the scrollport”,
    “the box is positioned above the top edge of the scrollport”
  ;
  time-range: 1s;
}

As with “regular” Scroll-Linked Animations we can drive an animation while scrolling between these two Element-based Offsets.

☝️ In an earlier version of the spec one had to define the Scroll Offsets using start and end descriptors.

@scroll-timeline element-enters-and-leaves-the-scrollport {
    start: “the box is positioned underneath the bottom edge of the scrollport”;
    end: “the box is positioned above the top edge of the scrollport”;
    time-range: 1s;
}

This is no longer the case, and one should now use the scroll-offsets descriptor instead.

However, you might still see this older syntax in the demos as Chromium has this older version implemented and is in the process of migrating to the new scroll-offsets syntax — Relevant Chromium Bug: 1094014

~

# Element-based Offsets in CSS

To create a Scroll-Linked Animation that uses Element-based Offsets, we first need two Element-based Offsets:

  1. A from offset which defines when the animation will begin.
  2. A to offset which defines by when the animation will be done.

In CSS, these Element-based Offsets are represented by the <element-offset> Data Type.

~

# The <element-offset> Data Type

Let’s take the example below and dissect it:

selector(#element) end 0
  1. selector(#element) defines the DOM Element we want keep an eye on. We call this the target.

  2. end indicates at which edge of the source‘s scrollport the algorithm should look for the target intersecting.

    • Allowed values are start/end.
    • Depending on the timeline’s orientation this translates to the top/bottom or left/right edge.
  3. The threshold is a number — ranging from 0.0 to 1.0 — that indicates how much of the target is visible in the scrollport at the given edge.

    • 0 = not in the scrollport
    • 1 = entirely in the scrollport

    You might already know this value from creating an IntersectionObserver.

Looking back at our example of selector(#element) end 0, it basically translates to:

  1. Keep an eye on #element
  2. … and track if it intersects at the end edge of source.
  3. If the element’s intersecting threshold is 0 then activate.

E.g. the #element is touching the scrollport at its bottom edge; which happens to be the starting point from the visualization before!

~

# Making it visual

If you cannot follow with that threshold there, don’t worry: it’s easier to understand if you make it visual.

See the Pen Scroll-Linked Animations Visualization: Element-Based Offsets Visualizer by Bramus (@bramus) on CodePen.

In the visualization above try changing the values, keeping in mind that the threshold is a number that indicates how much of the target is intersecting with the scrollport at the given edge.

If, after playing with it, you understand that a edge+threshold combo of start 0.5 means that the target is halfway across the top edge of a vertical scrollport, you get it 😎

🐛 I’ve noticed that it’s also possible to — for example — define end 1.2, which translates to “the target is 1/5th over the bottom edge”. This is not allowed per spec, as the threshold should be in the range of 0.0 and 1.0.

However, as the algorithm never checks whether this value is inside its assigned range, it passes through and will work. I kinda like this quirk, as it allows you to add some breathing room to all of your animations.

Could be in the future that this will no longer be allowed — Relevant CSS WG Issue: 5203

~

# Element-based Offsets and @scroll-timeline (Revealing Image Demo)

To create a Scroll-Linked Animation that uses Element-based Offsets, you need to pass a pair of <element-offset> Data Types into the @scroll-timeline‘s scroll-offsets descriptor.

@scroll-timeline element-enters-and-leaves-the-scrollport {
  scroll-offsets:
    selector(#element) end 0,
    selector(#element) start 0
  ;
  time-range: 1s;
}
  1. selector(#element) end 0 here is our from offset, and defines when the animation will begin.
  2. selector(#element) start 0 here is our to offset, and defines by when the animation will be done.

Here the offsets for our @scroll-timeline are set so that the animation will begin when #element is about to enter the scrollport from the bottom (= end edge, 0% in view), and will be done animating after the #element has entirely left the scrollport at the top (= start edge, 0% in view).

In this demo you’ll see an image be revealed as it intersects with the scrollport. The reveal itself is done using a clip-path that animates from inset(0% 50% 0% 50%); to no clipping at all, which looks like a curtain opening.

@keyframes reveal {
    to {
        clip-path: inset(0% 0% 0% 0%);
    }
}
.revealing-image {
    clip-path: inset(0% 50% 0% 50%);
    animation: reveal 2s linear;
    animation-fill-mode: forwards;
}

@scroll-timeline revealing-image-timeline {
    source: selector(body);
    scroll-offsets:
      selector(#revealing-image) end 0,
      selector(#revealing-image) start 0
    ;
    time-range: 2s;
}
#revealing-image {
    animation-timeline: revealing-image-timeline;
}

🤕 In this demo we have a loss of (visual) data though. As our animation reaches 100% only when the image has already slid out of scrollport (at the top), we can never see the image as a whole — it’s always clipped while inside the scrollport. Thankfully we can tweak the used <element-offset>s to prevent this loss of visual data.

~

# Typical from/to <element-offset> Combinations

There are 4 typical edge + threshold combinations to use with Scroll-Linked Animations:

  • start 0
  • start 1
  • end 1
  • end 0

☝️ As a reminder, here’s what they look like, individually:

See the Pen Scroll-Linked Animations Visualization: Element-Based Offsets Visualizer by Bramus (@bramus) on CodePen.

Other values for threshold of course still possible; I’m only taking a look at these extremes here.

Depending on how you combine these as to/from offsets, we can control when exactly the animation will run:

  • #element is intersecting scrollport, even for the tiniest bit
  • #element is in between scrollport edges
  • #element is entering from bottom into scrollport
  • #element is exiting at top from scrollport

In the demo below I’ve created several boxes that each have a different pair of <element-offset>s applied. Scroll down to see the elements appear in the viewport and take a good look at each box separately, specifically when one of its edges enters or leaves the viewport.

Color codes are applied to indicate when the element is being animated:

  • Red = the Scroll Timeline is not animating the element
  • Green = the Scroll Timeline is animating the element

Unfortunately there’s an issue with the used ScrollTimeline polyfill used, and the timelines are calculated wrongly. Showing this (broken) demo would only confuse you more, so it’s not included. Please to see how it behaves.

Did you see? Hit the checkbox at the top to have the demo show the findings. Perhaps you’ll see it now 😉

Interpreting the results from the demo, I’ve forged this small list of typical from-to offset combinations and what they look like:

  • end 0start 0 = intersecting scrollport, even for the tiniest bit
  • end 1start 1 = in between scrollport edges
  • end 0end 1 = enter from bottom into scrollport
  • start 1start 0 = exit at top from scrollport

In this extra visualization below you can see how these different combinations affect the timeline (drawn in the center). The same colors as in the demo are used.

See the Pen Scroll-Linked Animations Visualization: Element-Based Offsets Timeline Visualizer by Bramus (@bramus) on CodePen.

🥵 Don’t sweat it if you don’t understand this all immediately; it also took me quite some time before I did. And perhaps there’s no need to, as you can go a long way with this little cheat sheet:

  • end 0start 0 = intersecting scrollport, even for the tiniest bit
  • end 1start 1 = in between scrollport edges
  • end 0end 1 = enter from bottom into scrollport
  • start 1start 0 = exit at top from scrollport

~

# Demos

As I have been playing with CSS @scroll-timeline for nearly a month by now, I’ve whipped up quite a lot of extra demos. Ready to have your socks blown off? Here goes:

  1. Revealing Images Demo, Revisited
  2. Contact List Demo
  3. Contact List Demo, Revisited
  4. Horizontal Scroll Section Demo
  5. CoverFlow Demo
  6. Stacking Cards Demo

☝️ Know that all these demos here are technical demos. On a real website you might want to go easy with these types of animations in case visitors request so, by respecting their prefers-reduced-motion setting.

~

# Revealing Images Demo, Revisited

This demo is similar to the first Revealing Image one, yet the offsets were tweaked in such a way that the revealing animation should only start when the image is already halfway in view (read: threshold of 0.5) and be finished by the time the image has entered the scrollport completely.

In @scroll-timeline speak that becomes:

@scroll-timeline revealing-image-timeline-1 {
  source: selector(body);
  scroll-offsets:
    selector(#revealing-image-1) end 0.5,
    selector(#revealing-image-1) end 1
  ;
  time-range: 2s;
}

Above that the start clip-path was set to inset(45% 20% 45% 20%); — making it look like a revealing box — and the opacity is also animated.


.revealing-image {
  opacity: 0;
  clip-path: inset(45% 20% 45% 20%);
  …
}

~

# Contact List Demo

In this demo I’ve created a contact list where new items slide in when they enter the scrollport. I think it makes a neat effect 🙂

The list itself is a regular <ul> which acts as the @scroll-timeline‘s source. Each <li> has it’s own scroll-offset set to go from end 0 (= bottom edge, out of view) to end 1 (= bottom edge, in view).

@keyframes slide-in {
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

li {
  opacity: 0;
  transform: translateX(-100%);
  animation: 1s slide-in ease-in forwards;
}

@scroll-timeline list-item-15 {
  source: selector(#list-view);
  scroll-offsets:
    selector(#list-item-15) end 0,
    selector(#list-item-15) end 1
  ;
  time-range: 1s;
}
#list-item-15 {
  animation-timeline: list-item-15;
}
😳 ICYWW: No, I didn’t manually type out all those scroll-timelines

As also mentioned in the first part of this series it’s pretty annoying when it comes to creating scroll-timelines for many individual items as the selector() function requires you to pass an id into it. This is a shortcoming of the spec, and is something what will be tackled. Relevant CSS WG Issue: 5884

Until this issue is resolved — and to save myself from typing all those scroll-timelines out manually — I use a little piece of JavaScript to generate the timelines. These generated timelines can then be either copy-pasted into the CSS, or dynamically injected from the script.

const generateCSS = () => {
  const css = [];
  document.querySelectorAll('li').forEach((li, i) => {
    const id = li.getAttribute('id');

    css.push(`
      @scroll-timeline list-item-${id} {
        source: selector(#list-view);
        scroll-offsets:
          selector(#${id}) end 0,
          selector(#${id}) end 1
        ;
        time-range: 1s;
      }
      #${id} {
        animation-timeline: list-item-${id};
      }   

    `);
  });
  return css.join("\n");
}

const injectCSS = (css) => {
  const style = document.createElement('style');
  style.innerHTML = css;
  document.head.appendChild(style);
}

injectCSS(generateCSS());

In some demos you might notice that the generateCSS() code is still present, but not called. And even if it were: those demos still are pure CSS 😉

The animation on the <li>s in this demo was carefully chosen to be a horizontal one, so that the dimensions of the of the wrapping <ul>‘s scrollport don’t change. If the <li> elements would be translated in a vertical direction, that would also adjust the scrollport. You can work around this by animating not the <li> itself, but by animating its contents.

🔥 TIP: Beware with animations that alter the dimensions of the source‘s scrollbox. Work around it by not animating the target, but by animating the target’s contents.

💡 By flipping the animation and adjusting the scroll-offsets so that they are triggered at the start edge, you can easily create a version where items slide-out as they scroll out of the scrollport.

@keyframes slide-out {
  to {
    opacity: 0;
    transform: translateX(100%);
  }
}

li {
  opacity: 1;
  transform: translateX(0);
  animation: 1s slide-out ease-in forwards;
}

@scroll-timeline list-item-1 {
  source: selector(#list-view);
  scroll-offsets:
    selector(#list-item-1) start 1,
    selector(#list-item-1) start 0
  ;
  time-range: 1s;
}
#list-item-1 {
  animation-timeline: list-item-1;
}

~

# Contact List Demo, Revisited

Combining both the fly-in and fly-out effect is possible by creating 2 animations, each with their own individual (non-overlapping) @scroll-timeline

/* Element scrolls into the scroll-container (from the bottom) */
@scroll-timeline tl-list-item-12-appear {
  source: selector(#list-view);
  scroll-offsets:
    selector(#list-item-12) end 0,
    selector(#list-item-12) end 1
  ;
  time-range: 1s;
}

/* Element scrolls out of the scroll-container (at the top) */
@scroll-timeline tl-list-item-12-disappear {
  source: selector(#list-view);
  scroll-offsets:
    selector(#list-item-12) start 1,
    selector(#list-item-12) start 0
  ;
  time-range: 1s;
}

#list-item-12 > * {
  animation:
    1s li-appear linear tl-list-item-12-appear,
    1s li-disappear linear tl-list-item-12-disappear
  ;
}

Unfortunately there’s an issue with the used ScrollTimeline polyfill used, disallowing the combination of two animations. Please to see how this demo behaves.

What’s very crucial here — apart from having non-overlapping Scroll Timelines — is the attaching of the animations. Whereas in the previous demos it had animation-fill-mode: both; set, this has now been removed. This is a very important thing to do. If you don’t you’ll notice that only the last animation will be applied and might think this is an implementation bug as I did. Thankfully it’s not a bug and was a classic case of PEBKAC.

~

# Horizontal Scroll Section Demo

This demo is based upon this “GSAP ScrollTrigger & Locomotive Scroll” demo by Cameron Knight, which features a horizontal scroll section. I’ve basically left the HTML intact, removed all JS, and added a few CSS Animations + Scroll Timelines to get this working.

The tricky part was the horizontal section of course:

  1. Stretch out the wrapping #sectionPin to 500vh so that we create extra room to scroll.
  2. Set up a @scroll-timeline for #sectionPin while it’s inside entirely covering the scrollport, using the mind-flipping start 1end 1 offset combination.

    @scroll-timeline horizontal-section-scrolling-into-view {
      source: selector(body);
      scroll-offsets:
        selector(#sectionPin) start 1, /* Start when #sectionPin touches the top edge of the scrollport and is visible inside the scrollport */
        selector(#sectionPin) end 1 /* End when #sectionPin touches the bottom edge of the scrollport and is visible inside the scrollport */
      :
      time-range: 1s;
    }

    This weird offset combination only works because #sectionPin is bigger than the scrollport. I’ll leave it to you, the reader, to dig into it further 😉

  3. On .pin-wrap use position: sticky; (so that it remains in view) and translate it horizontally while scrolling.

    @keyframes move-horizontal-scrolling-section {
      to {
        transform: translateX(calc(-100% + 100vw)); /* Move horizontally so that right edge is aligned against the viewport */
      }
    }
      
    .pin-wrap {
      position: sticky;
      top: 0;
    
      animation: 1s linear move-horizontal-scrolling-section forwards;
      animation-timeline: horizontal-section-scrolling-into-view;
    }
☝️ What’s remarkable here is that the @scroll-timeline is tracking #sectionPin, but the animation is applied to it’s child element .pin-wrap. This is truly one of the powers of @scroll-timeline.
🐛 Want to dig deeper? Here’s a rendering glitch you might notice:

Upon reversing the animation when scrolling back up, it glitches.

This is a confirmed bug which will hopefully be fixed soon. — Relevant Chromium Bug: 1175289

~

# CoverFlow Demo

Remember CoverFlow from iTunes? Well, here’s a CSS-only version built with @scroll-timeline! 🎉

Looking under hood, here’s how it’s structured:

  1. The <ul> element is the scroll container, and the <li> elements inside scroll.
  2. The <li> elements themselves switch z-index, and their contained <img /> elements are transformed.

That 2nd thing is quite key here: If we were to transform the <li> elements the scroll-container’s width would change. This would make the scroll-linked animation recalculate at every change, resulting in a flickering animation. We don’t want that of course.

💿 Album covers in this demo come from the wonderful Loci Records label. You should definitely check them out. Their “Season Two” compilation is a good start: https://locirecords.com/season-two/

🐛 In this demo you’ll notice the native ScrollTimeline implementation in Chromium being buggy. Click for more details/info.

If you have “Experimental Web Platform Features” enabled you’ll notice two bugs in this demo:

  1. The z-index is not always applied correctly (slight flicker).
  2. (Not shown in video above) Elements at the very start over very end animate “too late”/”too soon”

You can see this second issue in the recording below:

On load card_0 and its siblings start at 0% animation progress, no matter what their position inside the scroll-container. When scrolling horizontally you see the first few items “catch up” with their animation state. By hitting card_5 the animation renders as expected: the item shown in the middle is at animation progress 50%. You can this very same wrong behavior again when closing in on the right edge: the last 5 items have the same issue (but in reverse). By the time the last card is in the center, its animation progress is 100%, whereas it should be at 50%.

This debug session shows best:

For reference, in a non-buggy implementation the initial rendering would show card_0 at 50% animation progress, as it’s positioned halfway the scrollport:

Relevant Chromium Bug: 1174838

~

# Stacking Cards Demo

In this demo I tried recreating this Stacking Cards demo from CodyHouse, initially by Claudia Romano.

See the Pen 🌟 Stacking Cards, Final Version by Bramus (@bramus) on CodePen.

Thanks to playing with animation-delay I’m able to use one shared @scroll-timeline for each card

:root {
  --numcards: 4;
}

#card_1 { --index: 1; }
#card_2 { --index: 2; }
#card_3 { --index: 3; }
#card_4 { --index: 4; }

@scroll-timeline cards-element-scrolls-in-body {
  source: selector(body);
  scroll-offsets:
    selector(#cards) start 1, /* Start when the start edge touches the top of the scrollport */
    selector(#cards) start 0 /* End when the start edge touches the start of the scrollport */
  ;
  time-range: 4s;
}

.card {
  --index0: calc(var(--index) - 1); /* 0-based index */
  --reverse-index: calc(var(--numcards) - var(--index0)); /* reverse index */
  --reverse-index0: calc(var(--reverse-index) - 1); /* 0-based reverse index */
}

@keyframes scale {
  to {
    transform: scale(calc(1.1 - calc(0.1 * var(--reverse-index))));
  }
}
	
.card__content {
  transform-origin: 50% 0%;
  will-change: transform;

  --duration: calc(var(--reverse-index0) * 1s);
  --delay: calc(var(--index0) * 1s);
  
  animation: var(--duration) linear scale var(--delay) forwards;
  animation-timeline: cards-element-scrolls-in-body;
}

At first I struggled a lot with getting the sticky part right, as my position: sticky; would work as expected, but not as I wanted: the last card slid over the preceding ones:

After doing a detour with manually trying to translate the cards, I eventually found how to get my position: sticky; to work as I wanted.

  1. Don’t vary top by an extra 1em per sticky item, but fixate top: 0; and adjust the padding-top to achieve the offset.
  2. Lay out all cards in their wrapper using CSS Grid with fixated rows. Their padding simply bleeds out (huh? 🤯), and therefore they will be evenly spaced using gap.

Cool part is: browsers that don’t understand @​scroll-timeline but do speak position: sticky; will also see the effect, but simply without the scaling.

~

# In Closing

With those fine demos we conclude this second part of this series! We’ve covered how to create Scroll-Linked Animations based on the location of an element within the scroller, and how we can tweak their offsets. I think it’s pretty amazing what we can do with them … the amount of JavaScript that can be replaced with it will be huge.

I hope I’ve been able to get you excited for this possible future addition to CSS throughout this post. Although it still is in its very early stages, I’m confident this will become a CSS WG Recommendation one day 🙂

I’m glad to see that the Chromium engineers are actively working on this experimental implementation, taking the time to respond to newly reported bugs. I hope that other browser vendors will follow suit soon. Relevant tracking bugs to flag/star/follow:

🗃 You can find all demos shown in this post over at CodePen, in a Collection Scroll-Linked Animations: Part 2. It’d be great if you could ❤️ the collection and/or the demos you like.

~

To help spread the contents of this post, feel free to retweet its announcement tweet:

Follow @bramus (= me, the author) and/or @bramusblog (= the feed of this blog) on Twitter to stay-up-to date with future posts. RSS also available.

~

Did this help you out? Like what you see?
Thank me with a coffee.

I don't do this for profit but a small one-time donation would surely put a smile on my face. Thanks!

☕️ Buy me a Coffee (€3)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.

The Future of CSS: Scroll-Linked Animations with @scroll-timeline (Part 1)

Example of what is possible with Scroll-Linked Animations, using only CSS

The Scroll-linked Animations Specification is an upcoming addition to CSS that defines a way for creating animations that are linked to a scroll offset of a scroll container. Even though the specification is still in draft, and in no way finalized nor official, it already has experimental support in Chromium.

The past few weeks I’ve been playing with the CSS @scroll-timeline at-rule and the animation-timeline CSS property this specification provides. By combining these two features with regular CSS Animations we can create Scroll-Linked Animations using only CSS — not a single line of JavaScript in sight!

In this first part of this series we’ll take a look at Scroll-Linked Animations between two absolute scroll-offsets, and how we can tweak them. In the second part of this series (published here) we’ll cover how to create Scroll-Linked Animations based on the location of an element within the scroller.

~

👨‍🔬 The CSS features described in this post are still experimental and not finalized at all! If you’re feeling adventurous you can play with these new features today, but you’ll need at least Chromium 89 with the #experimental-web-platform-features flag enabled through chrome://flags.

💥 To keep your primary Chrome install clean, I recommend you do not set this in Chrome Stable, but resort to Beta / Canary builds.

👀 If you don’t understand how to do this, or don’t feel safe doing this, fear not: This post also includes recordings and/or fallback versions using JavaScript for most of the demos.

💄 While the Scroll-Linked Animations Specification also describes a JavaScript interface, the main focus of this post will be its CSS counterpart. The JS alternatives won’t be covered in detail.

~

Table of Contents

  1. Primer: Scroll-Linked Animations vs. Scroll-Triggered Animations
  2. Your first Scroll-Linked Animation (Progress Bar Demo)
  3. Tweaking the Offsets (Parallax Cover Demo)
  4. Changing the Scroll Orientation
  5. Changing the Scroll Container (In-Page Gallery Demo)
  6. In-Between Summary
  7. More Demos
    1. Parallax Cover to Sticky Header Demo
    2. Full Screen Panels with Snap Points Demo
    3. Full Screen Panels with Snap Points Demo, With Navigation Controls
  8. In Closing

~

# Primer: Scroll-Linked Animations vs. Scroll-Triggered Animations

Before we jump into the CSS code, there’s this difference that we need to make between Scroll-Linked Animations and Scroll-Triggered Animations

Scroll-Linked Animations are animations are linked to the scroll offset of a scroll container. As you scroll back and forth the scroll container, you will see the animation timeline advance or rewind as you do so. If you stop scrolling, the animation will also stop.

Think of a progress bar shown on top of a page, where there is a direct link between the scroll progress and size of the progress bar. Hit the ⏮ and ⏭ buttons in the visualization below to see how it behaves.

See the Pen Scroll-Linked Animations Visualization: Progressbar by Bramus (@bramus) on CodePen.

Using Scroll-Linked Animations you can animate elements as a scroll container scrolls.

Scroll-Triggered Animations are animations that are triggered when scrolling past a certain position. Once triggered, these animations start and finish on their own, independent of whether you keep scrolling or not.

Think of those typical “content flies in as it enters the viewport” animations. Hit the ⏮ and ⏭ buttons in the visualization below to see how it behaves.

See the Pen Scroll-Triggered Animations Visualization: Fly-In Content by Bramus (@bramus) on CodePen.

Using Scroll-Triggered Animations you can animate elements as they enter/exit the scrollport

~

# Your first Scroll-Linked Animation (Progress Bar Demo)

Instead of getting technical straight away, let’s take a look at a Progress Bar that is implemented using Scroll-Linked Animations, and dissect it from there.

What you see there — if your browser supports it — is a scrollbar that progresses from 0 to 100% as you scroll down the page. All this is done using only CSS, and running in a non-blocking way on the compositor thread (e.g. “off main thread”)! 🤩

Apart from positioning and what not, the code that drives this demo is this little piece of CSS:

/* (1) Define Keyframes */
@keyframes adjust-progressbar {
    from {
        transform: scaleX(0);
    }
    to {
        transform: scaleX(1);
    }
}

/* (2) Define a ScrollTimeline */
@scroll-timeline progressbar-timeline {
    time-range: 1s;
}

/* (3) Attach the Animation + set the ScrollTimeline as the driver for the Animation */
#progressbar {
    animation: 1s linear forwards adjust-progressbar;
    animation-timeline: progressbar-timeline; /* 👈 THIS! */
}

We recognise 3 key components that we need to make it all work:

  1. An Animation
  2. A Scroll Timeline
  3. A way to link both

~

# The Animation

This is a a regular CSS Animation. In case of our progress bar it’s an animation that goes from zero width to full width.

@keyframes adjust-progressbar {
    from {
        transform: scaleX(0);
    }
    to {
        transform: scaleX(1);
    }
}

#progressbar {
    width: 100vw;
    transform: scaleX(0);
    transform-origin: 0 50%;
    animation: 1s linear forwards adjust-progressbar;
}

There’s a few things to note about this animation:

  • To optimize this animation for the browser we don’t animate the width property, but fixate the width to 100vw and animate transform: scaleX(…); instead. To make that work properly we have to set the transform-origin to the left edge of the element.
  • To prevent a FOUC we apply the start scaleX(0); transform directly onto the #progressbar element.
  • To make sure this animation remains in its end state when it has finished, we set animation-fill-mode to forwards.
  • The values for animation-duration (1s) and animation-timing-function (linear) look like they are chosen arbitrarily here, but they’re not. We’ll dig into these further down.

Now, if you implement this piece of CSS as-is, you’ll see this animation run all by itself. This is because we have not created nor linked a Scroll Timeline yet, which follow next.

~

# The Scroll Timeline

As we scroll through the document from top to bottom (e.g. from 0% to 100%) we want our animation to also go from start to finish (e.g. from 0s to 1s). For this we need a Scroll Timeline. It is a type of timeline whose actual time value is determined by the progress of scrolling in a scroll container.

To define a ScrollTimeline in CSS, we can use the new @scroll-timeline at-rule, and configure it using descriptors:

  1. source
  2. orientation
  3. scroll-offsets
  4. time-range

For our Progress Bar we only need the time-range descriptor to make it work, and it looks like this:

@scroll-timeline progress-timeline {
    time-range: 1s;
}

Here we have created a Scroll Timeline with the name progress-timeline. The value set for the time-range descriptor is a CSS <time> Data Type. In this case here it does not represent the time of a clock though, but is a number that maps Scroll Progress to Animation Progress. It gives an answer to the question “How much animation time should pass when we scroll from start to finish in the scroll container?”

As we have defined our animation-duration to be 1s from start to finish, we want our time-range to reflect that same duration, namely 1s: Scrolling from top to bottom (e.g. from 0% to 100%) should advance the animation by 1s.

Our scroll-to-time mapping internally looks like this:

  • 0% Scroll Progress equals 0s Animation Progress.
  • 100% Scroll Progress equal s1s Animation Progress.

(All values in between are interpolated, so 50% Scroll Progress will equal 0.5s Animation Progress)

🔥 TIP: Always set time-range to the exact same time as the animation-duration, unless you have a very good reason not to.

🚨 It’s very important to understand that this time-range descriptor does not represent the time of a clock, but is nothing more than a mapping.

A few examples to make this more clear:

  • Say animation-duration + time-range are both 1s:

    • 0% of scrolling maps to 0s of the timeline
    • 100% of scrolling maps to 1s of the timeline

    👉 In this case the animation will go from start to finish a you scroll from top to bottom.

  • Say animation-duration is 1s and time-range is 2s:

    • 0% of scrolling maps to 0s of the timeline
    • 100% of scrolling maps to 2s of the timeline

    👉 Here the animation will go twice as fast as you scroll to the bottom. E.g. if you’re halfway in the scroll container, the animation will already be complete!

~

# Linking up both

To associate our @scroll-timeline with our CSS Animation we use the new animation-timeline CSS property, and have it refer to the timeline’s name.

#progressbar {
    animation: 1s linear forwards adjust-progressbar;
    animation-timeline: progressbar-timeline; /* 👈 THIS! */
}

This is the part where our animation-timing value of linear comes into play: it enforces a 1-on-1 mapping between Scroll Progress and Animation Progress. If we were to set our timing to something like ease-in instead, we’d see our progress bar be too slow at the beginning and speed up towards the end as we scroll. This feels really weird to be honest.

🔥 TIP: Always set animation-timing-function to linear when working with @scroll-timeline.

~

# Tweaking the Offsets (Parallax Cover Demo)

By default a @scroll-timeline will be linked to scrolling vertically from top to bottom across the document. But what if we our animation to start/stop when having scrolled for a specific (~ fixed) distance? This is where the scroll-offsets descriptor comes into play.

😵 As reader Patrick H Lauke points out you might want to go easy with the type of animation shown below in case visitors request so, by respecting the setting of prefers-reduced-motion.

In this example we have a full-page (100vh) parallax cover. For it to work correctly we want our animation to begin at the start of the document and to be finished after scrolling 100vh into the document (instead of the default “100% of the document”).

To make this happen we set our Scroll Offsets to 0 (start) and 100vh (end). The resulting @scroll-timeline definition looks like this:

@scroll-timeline parallax-header-timeline {
    scroll-offsets: 0%, 100vh;
    time-range: 1s;
}

You can put any <length> or <percentage> Data Type in there.

☝️ In an earlier version of the spec one had to define the Scroll Offsets using start and end descriptors.

@scroll-timeline parallax-header-timeline {
    start: 0%;
    end: 100vh;
    time-range: 1s;
}

This is no longer the case, and one should now use the scroll-offsets descriptor instead.

However, you might still see this older syntax in the demos as Chromium has this older version implemented and is in the process of migrating to the new scroll-offsets syntax — Relevant Chromium Bug: 1094014

If you want, you can also put in more than two values, but note that your scroll to time mapping might become wonky. With scroll-offsets: 0vh, 80vh, 100vh; and a time-range of 1s for example, your scroll-time map will become this:

  • At 0vh your time-range will have advanced to 0s
  • At 80vh your time-range will have advanced to 0.5s, as that 80vh is defined “halfway the array of values”
  • At 100vh your time-range will have advanced to 1s
🔥 TIP: Always set two values for scroll-offsets, unless you have a specific reason not to.

☝️ The scroll-offsets can accept more types of values, which we will cover further down this post.

~

# Changing the Scroll Orientation

By default a @scroll-timeline will be linked to scrolling vertically from top to bottom across the document. Using the orientation descriptor we can change this to — for example — horizontal.

@scroll-timeline example {
    orientation: horizontal;
    time-range: 1s;
}

Use of the logical values inline and block is also allowed. Finally, there’s also auto.

~

# Changing the Scroll Container (In-Page Gallery Demo)

By default a @scroll-timeline will be linked to scrolling vertically from top to bottom across the document. But what if we don’t want across the document, but inside a specific element? This is where the source descriptor comes into play.

Below is an example that contains two in-page image galleries/carousels, implemented using scroll-snapping. Each of those have a progress bar attached. To drive these progress bars we need not want to respond to scroll progress in the document, but to scrolling in their own scroll container.

To define which scroll container a @scroll-timeline responds to, you need set the source descriptor, and have it target said element. To do so you can use the selector() function as its value. That function requires an <id-selector>, so you’ll need to give your targeted element an id attribute value.

@scroll-timeline example {
    source: selector(#foo);
    time-range: 1s;
}

As we have two galleries, we need to define two @scroll-timeline instances and connect them to their proper progress bar. And since they are horizontally scrolling ones, we also need to set the orientation descriptor correctly. Our code eventually looks like this:

<div class="gallery" id="gallery1">
    <div class="gallery__progress" id="gallery1__progress"></div>
    <div class="gallery__scrollcontainer" id="gallery1__scrollcontainer">
        <div class="gallery__entry">
            …
        </div>
        <div class="gallery__entry">
            …
        </div>
    </div>
</div>
@keyframes progress {
	to {
		transform: scaleX(1);
	}
}

/* #gallery1 */
@scroll-timeline gallery1__timeline {
	source: selector(#gallery1__scrollcontainer);
	orientation: horizontal;
	time-range: 1s;
}
#gallery1__progress {
	/* We have 2 photos, with the 1st visible, so we start at 1/2 */
	transform: scaleX(0.5);
	animation: 1s linear forwards progress;
	animation-timeline: gallery1__timeline;
}

/* #gallery2 */
@scroll-timeline gallery2__timeline {
	source: selector(#gallery2__scrollcontainer);
	orientation: horizontal;
	time-range: 1s;
}
#gallery2__progress {
	/* We have 3 photos, with the 1st visible, so we start at 1/3 */
	transform: scaleX(0.333);
	animation: 1s linear forwards progress;
	animation-timeline: gallery2__timeline;
}

😖 One thing I find pretty annoying when it comes to this selector() function is that you must pass an id into it. This can become pretty cumbersome: with 10 galleries on a page, you need to define 10 almost identical @scroll-timelines in your code. Only difference between them: the id passed into selector().

I consider this to be shortcoming of the specification, and have raised an issue with the CSSWG: it would be handy if selector() could point to the current element being animated or would accept any selector. That way you can reuse one single @scroll-timeline on multiple elements.

Relevant CSS WG Issue: 5884

💡 If you think you would be able to dynamically set the <id-selector> in source by means of CSS Custom Property, don’t bother: CSS Variables cannot be used within descriptors.

~

# In-Between Summary

📝 Before we continue with the really cool stuff that’s coming up, let’s summarize what we know so far.

A Scroll Timeline is an interface that lets us map Scroll Progress to Animation Progress. You can define it in CSS using @scroll-timeline with the following descriptors:

source
The scrollable element whose scrolling triggers the activation and drives the progress of the timeline.
orientation
The direction of scrolling which triggers the activation and drives the progress of the timeline.
scroll-offsets
An array of two or more scroll offsets that constitute the in-progress intervals in which the timeline is active.
time-range
A duration that maps the amount scrolled between the Scroll Offsets to the duration of an animation.

Allowed values for the descriptors:

  • By default the source is the document’s scrolling element (value: auto), but you can also target an element using selector(<id-selector>)
  • The orientation is vertical or horizontal. Using logical units inline and block is also possible. The initial value is auto.
  • Typically the entries in scroll-offsets are lengths or percentages, but we’ll cover an extra variation in the next part
  • Easiest setting for time-range is the same value as the animation’s animation-duration.

To attach a @scroll-timeline to an animation, use the animation-timeline property.

~

# More Demos

As I have been playing with CSS @scroll-timeline for nearly a month by now, I’ve been making quite a lot of demos. Here’s a fine selection relevant for this first part of this series:

  1. Parallax Cover to Sticky Header Demo
  2. Full Screen Panels with Snap Points Demo
  3. Full Screen Panels with Snap Points Demo, With Navigation Controls

~

# Parallax Cover to Sticky Header Demo

Building further upon the Parallax Cover from earlier on, here’s a demo that converts a full page Cover Image to a Sticky Header.

The @scroll-timeline is exactly the same as the Parallax Cover demo, only the animation is a bit different: the color, font-size, and height are also adjusted upon scrolling.

I couldn’t use position: sticky; here though, as resizing the cover would shrink down the entire height of the document, and therefore the animation would flicker. Instead I resorted to position: fixed; and added a margin-top of 100vh to the text content so that it remains visually below the cover.

~

# Full Screen Panels with Snap Points Demo

This is a small demo forked from this demo by Adam Argyle, which put CSS @scroll-timeline on my radar (thanks, Adam!). The page features a 4-panel full-page carousel with numbers that slide into view.

The demo has been adjusted to use CSS @scroll-timeline and mix-blend-mode: difference;.

The / 4 suffix is position: fixed; on the page, and the / character inside spins around 1turn per panel that you scroll. As there are 4 panels in total, we spin for a total of 3turn from top to bottom of the scroll container.

@scroll-timeline spin-slash {
  source: selector(#main);  
  time-range: 1s;
}

@keyframes rotato {
  to {
    transform: rotateZ(3turn);
  }
}

.slash {
  animation: rotato 1s linear;
  animation-timeline: spin-slash;
}

~

# Full Screen Panels with Snap Points Demo, With Navigation Controls

This demo builds further upon the previous one and adds a navigation bar to it. The active indicator is powered by @scroll-timeline: as you scroll through #main, the active indicator moves to the correct navigation item.

There are two variants for you to check:

  1. There is one single active indicator shared amongst all navigation items.
  2. Each navigation item has its own active indicator.

I like how in this second example these indicators reflect the percentage each section is in view (or not).

In the first version a line is injected underneath the navigation and its left position is adjusted using the same @scroll-timeline as the panels use.

In the second version each navigation item gets a line injected. The animation to show/hide the line is one shared animation for all items that does both the showing and the hiding:

@keyframes reveal-indicator {
  1% { /* We use 1% instead of 0% to prevent rounding/rendering glitches */
    transform: scaleX(0);
  }
  50% {
    transform: scaleX(1);
  }
  99% {  /* We use 99% instead of 100% to prevent rounding/rendering glitches */
    transform: scaleX(0);
  }
}

Now it gets tricky though: for each navigation item we create a different @scroll-timeline whose scroll-offsets and time-range vary.

  • The default time-range is 4s
  • The first and last items only need half an animation though (as you can’t scroll past them) so their time-range is set to 2s
  • To fix the first item’s animation we use a negative animation-delay of -2s on the element itself. That way it’s animation will start “too soon”, and will already be at 50% (thus at scaleX(1)) on page load.

~

# In Closing

That’s it for the first part of this series! We’ve covered how to create Scroll-Linked Animations between two absolute scroll-offsets, and how we can tweak our defined @scroll-timelines.

🔥 In the second part of this series (published here) we cover how to create Scroll-Linked Animations based on the location of an element within the scroller.

Follow @bramus (= me, the author) and/or @bramusblog (= the feed of this blog) on Twitter to stay-up-to date with future posts. RSS also available.

I hope I’ve been able to get you excited for this possible future addition to CSS throughout this post. Although it still is in its very early stages, I’m confident this will become a CSS WG Recommendation one day 🙂

I’m glad to see that the Chromium engineers are actively working on this experimental implementation, taking the time to respond to newly reported bugs. I hope that other browser vendors will follow suit soon. Relevant tracking bugs to flag/star/follow:

🗃 You can find all demos shown in this post over at CodePen, in a Collection Scroll-Linked Animations: Part 1. It’d be great if you could ❤️ the collection and/or the demos you like.

~

To help spread the contents of this post, feel free to retweet its announcement tweet:

~

Did this help you out? Like what you see?
Thank me with a coffee.

I don't do this for profit but a small one-time donation would surely put a smile on my face. Thanks!

☕️ Buy me a Coffee (€3)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.

Style Spelling and Grammar Errors with the ::spelling-error and ::grammar-error pseudo-elements

Part of the CSS Pseudo-Elements Level 4 Specification are ways to style spelling and grammar errors.

By default spelling errors — words you have mistyped — get a red dotted underline, thanks to the ::spelling-error pseudo-class selector you can tweak that. Grammar errors — such as a missing capital letter at the beginning of a sentence — can be styled with the ::grammar-error pseudo-class selector.

::spelling-error {
  text-decoration: underline wavy red;
}

::grammar-error {
  text-decoration: underline wavy blue;
}

~

Embedded below is a demo that shows the output of the browser you are visiting with, and the wanted output:


See the Pen The Future of CSS: Style Grammar and Spelling Errors (Demo) by Bramus (@bramus) on CodePen.

As you can see your browser does not yield the proper output, as ::spelling-error/::grammar-error currently have no support in any browser at the time of writing:

~

Once these highlight pseudo-elements do gain browser support, do note that they can only be styled by a limited set of properties that do not affect layout. Only following properties apply to the highlight pseudo-elements:

  • color
  • background-color
  • text-decoration and its associated properties
  • text-shadow
  • stroke-color, fill-color, and stroke-width

~

In case you are interested, here are the relevant bugs to flag/star/follow:

~

Did this help you out? Like what you see?
Thank me with a coffee.

I don't do this for profit but a small one-time donation would surely put a smile on my face. Thanks!

☕️ Buy me a Coffee (€3)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.

~

Style the target text from text-fragment links using the ::target-text pseudo-element

One of my favorite features that shipped with Chrome 80 — apart from Optional Chaining — is the ability to link to text-fragments. By default Chrome will highlight those in yellow:

~

As tweeted before, coming to Chromium 89 is the ability to style those text-fragments using the ::target-text pseudo-element, which is part of the CSS Pseudo-Elements Level 4 Specification.

This snippet below:

/* Style scroll-to-text fragments */
::target-text {
    background: #00cdff;
}

Will visually manifest itself like this:

~

Note that the ::target-text pseudo-element can only be styled by a limited set of properties that do not affect layout. Only following properties apply:

  • color
  • background-color
  • text-decoration and its associated properties
  • text-shadow
  • stroke-color, fill-color, and stroke-width

~

Did this help you out? Like what you see?
Thank me with a coffee.

I don't do this for profit but a small one-time donation would surely put a smile on my face. Thanks!

☕️ Buy me a Coffee (€3)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.

The future of CSS: Higher Level Custom Properties to control multiple declarations

When using CSS Custom Properties we mainly use them directly as variables in calculations for other properties. Having one CSS Custom Property control a varying set of other properties — such as both colors and numbers — is not exactly possible. There are some hacky workarounds we can use, but these don’t cover all scenarios. Thankfully there’s a new idea popping up: Higher Level Custom Properties. Although still premature, these Higher Level Custom Properties would allow us to drop the hacks.

Let’s take a look at our current options, and how this (possible) future addition to the CSS spec — along with the @if at-rule it introduces — might look …

~

~

# CSS Custom Properties as Variables

When working with CSS Custom Properties today, they are mainly used as CSS Variables. If you’ve used them, you’re quite familiar with code like this:

:root {
    --square-size: 2vw;
    --square-padding: 0.25vw;
}

.square {
    width: var(--square-size);
    padding: var(--square-padding);
    aspect-ratio: 1/1;
}

.square--big {
    --square-size: 16vw;
    --square-padding: 1vw;
}

Using the var() function we create a CSS Variable which gets substituted for the value of the Custom Property it refers to.

E.g. The variable var(--square-size) will hold the value of the --square-size Custom Property — namely 2vw — which is then set as the value for the width CSS property.

🤔 CSS Custom Properties vs. CSS Variables — Is there a difference?

Yes there's a difference:

  • A CSS Custom Property is any property whose name starts with two dashes (U+002D HYPHEN-MINUS), like --foo. Just like with a normal property you can assign a value to it, e.g. --foo: 200;.
  • A CSS Variable is created when the var() function is used. When creating the CSS Variable var(--my-prop), it will be replaced with the value of the --my-prop Custom Property it refers to, namely 200.

~

# Using CSS Custom Properties to affect multiple CSS declarations

In the example above we have two types of squares: regular sized ones and big ones. To differentiate between them we need to toggle the .square--big class. Toggling that class affects two CSS Custom Properties: both --square-size and --square-padding are altered.

But what if we wanted not to toggle a HTML class but a CSS Custom Property to do so? E.g. we want to toggle one CSS Custom Property, and have that automatically affect both --square-size and --square-padding.

As it stands today it’s not very straightforward to let one single CSS Custom Property affect multiple other CSS Properties, unless you resort to some hacky workarounds. Let’s take a look at the options we have today.

~

# Binary Custom Properties

If all you’re setting is numeric values, you can use Binary CSS Custom Properties within calculations. You give these Binary Custom Properties the value of 0 or 1 and use them within your calculations. Think of these Binary Custom Properties like light switches: they can either be OFF/false (0) or ON/true (1).

:root {
    --is-big: 0;
}

.square--big {
    --is-big: 1;
}

.square {
    width: calc(
        2vw * (1 - var(--is-big)) /* Value to apply when --is-big is 0 (~false) */
        +
        16vw * var(--is-big) /* Value to apply when --is-big is 1 (~true): */
    );
    padding: calc(
        0.25vw * (1 - var(--is-big)) /* Value to apply when --is-big is 0 (~false) */
        +
        1vw * var(--is-big) /* Value to apply when --is-big is 1 (~true): */
    );
    aspect-ratio: 1/1;
}

In the example above the --is-big Custom Property acts as a binary toggle that controls the results of the calc() functions. In the case of --is-big having a value of 0 those functions will yield one specific value, while when --is-big is set to 1 it will yield another value.

☝️ With some extra effort you can even perform Logical Operations (AND, NAND, OR, NOR, XOR, …) using CSS Custom Properties!?

Ana Tudor worked out the math for us in Logical Operations with CSS Custom Properties:

:root {
    --j: 1;
    --k: 0;
}

element {
    --notj: calc(1 - var(--j));
    --and: calc(var(--k)*var(--i));
    --nand: calc(1 - var(--k)*var(--i));
    --or: calc(1 - (1 - var(--k))*(1 - var(--i)));
    --nor: calc((1 - var(--k))*(1 - var(--i)));
    --xor: calc((var(--k) - var(--i))*(var(--k) - var(--i)));
}

🤯

~

# The Guaranteed-Invalid Value Hack

When you need to set things other than numeric values — such as colors — you can’t rely on a toggle that is either 0 or 1, as performing calculations with colors is invalid.

.square {
    /* ❌ This won't work! ❌ */
    color: calc(
        hotpink * (1 - var(--is-big))
        +
        lime * var(--is-big)
    );
}

The spec detailing calc() is clear on this:

It can be used wherever <length>, <frequency>, <angle>, <time>, <percentage>, <number>, or <integer> values are allowed.

CSS Values and Units Level 3: 8.1 Mathematical Expressions: `calc()`

What you can do however is use The CSS Custom Property Toggle Trick by James0x57 — which I like to call “The Guaranteed-Invalid Value Hack” — where you set a Custom Property to the “guaranteed-invalid value” of initial to force the var() function to use its fallback value:

If, for whatever reason, one wants to manually reset a variable to the guaranteed-invalid value, using the keyword initial will do this.

CSS Custom Properties for Cascading Variables Module Level 1: 2.2. Guaranteed-Invalid Values

In code it boils down to this:

--my-var: initial; /* initial => var() will use the fallback value */
color: var(--my-var, green); /* ~> green */
--my-var: hotpink; /* Any value other than `initial` (even simply one space!) => var() will not use the fallback value */
color: var(--my-var, green); /* ~> hotpink */

That means that you can flip the switch ON by setting a Custom Property to the value of initial. Here’s an example where the text will turn green and italic once --is-checked is flipped on:

input[type="checkbox"] + label {
    --is-checked: ; /* OFF */
    color: var(--is-checked, green);
    border: var(--is-checked, none);
    font-style: var(--is-checked, italic);
}

input[type="checkbox"]:checked + label {
    --is-checked: initial; /* ON */
}

A limitation of this approach however is that you can’t define several values to use in case --is-checked is in the OFF state. Say I want the text in the example above to be both red by default and with a border. Setting --is-checked to red will only get me halfway, as that value is only valid for the color property here.

input[type="checkbox"] + label {
    --is-checked: red; /* Default value to use */
    color: var(--is-checked, green); /* ✅ Will be red by default */
    border: var(--is-checked, none); /* ❌ What about a default value for border? */
    font-style: var(--is-checked, italic); /* ❌ What about a default value for font-style? */
}

~

# Update 2020.01.22: The Space Toggle Trick

UPDATE: As James0x57 himself pointed out in the comments below, the “CSS Custom Property Toggle Trick” can be used for this, but it takes some adjustments when compared to the implementation above. Here’s what James0x57 calls the Space Toggle Trick:

  • Consider the value   (space) to be the ON position, and the value of initial to be the OFF position.
  • Assign property values to new custom properties using the syntax --value-to-use-if-custom-toggle-is-on: var(--my-custom-toggle) value;, where you put the value to be used after the CSS Variable.

    --toggler: initial;
    --red-if-toggler: var(--toggler) red;
  • To use the value, use the var() syntax as before (e.g. adding a fallback value):

    background: var(--red-if-toggler, green); /* will be green! */
  • If you have more than one property than can affect a toggle, you can chain them up:

    • AND Logic:

      --red-if-togglersalltrue: var(--tog1) var(--tog2) var(--tog3) red;
    • OR Logic:

      -red-if-anytogglertrue: var(--tog1, var(--tog2, var(--tog3))) red;

Here’s a pen that applies his technique, with some cleaned up property names:

See the Pen
3. Binary Custom Properties + “The CSS Custom Property Toggle Trick” (Renamed)
by Bramus (@bramus)
on CodePen.

Thanks for clarifying James0x57, as I only understood half of your hack before 😅

~

# Future Solution: Higher Level Custom Properties

So the problem is that, as it stands today, we can’t have one single CSS Custom Property affect a varying set of other CSS Properties, or at least not in an easy way. At the CSS WG Telecon from early December 2020 Lea Verou proposed something called “Higher Level Custom Properties”, which would allow exactly that!

🚨 Do note that this proposal is still in it’s very very early stages and part of an ongoing discussion. The CSS WG has merely expressed interest in this proposal, suggesting that it should be explored further. If if tends to be helpful and possible, only then work on a Working Draft will start. Right now it still is a concept.

~

# Definition and Example

“Higher Level Custom Properties” are Custom Properties that control a number of other CSS Properties. As the proposal stands right now you use them in combination with a newly proposed @if at-rule, like so:

.square {
    width: 2vw;
    padding: 0.25vw;
    aspect-ratio: 1/1;

    @if (var(--size) = big) {
        width: 16vw;
        padding: 1vw;
    }
}

Unlike the Custom Properties we know today, a Higher Level Custom Property controls multiple declarations, way beyond simple variable substitution. In the example above we set our HLCP --size to have a value of big. This value isn’t used directly, but affects the other properties width and padding.

Using this HLCP also improves the meaning of our code. Setting width: 16vw; does not clearly express our intent, whereas setting --size: big; does.

💁‍♂️ If you don’t like @if then please don’t discard the whole idea immediately, but focus on the problem it’s trying to fix here. Lea’s proposal is a possible solution, not the solution. Could be that — in the end — we end up with a totally different syntax.

~

# Issues that still need to be tackled

Before you get too excited, there are still some cases that need to be taken care of. In a follow-up comment on the proposal, Lea documented some already identified issues.

🚨 Note that these issues are blocking issues. As long as these aren’t resolved, HLCPs won’t happen.

# Partial Application

A first issue is a problem with the desugaring of @if and partial application. Behind the scenes a @if at-rule desugars to the still discussed if() function call. The example above eventually becomes this:

.square {
    width: if(var(--size) = big, 16vw, 2vw);
    padding: if(var(--size) = big, 1vw, 0.25vw);
    aspect-ratio: 1/1;
}

This leads to no issue here, but it becomes quirky when comparing against percentages for example.

E.g. consider this:

.foo {
	@if (1em > 5%) {
		width: 400px;
		height: 300px;
	}
}

which desugars to:

.foo {
	width: if(1em > 5%, 400px);
	height: if(1em > 5%, 300px);
}

Now consider that an element that matches .foo is inside a 600px by 400px container and has a computed font-size of 25px; This makes 1em > 5% evaluate to false on the width property and true on the height property, which would make the @if partially applied. We most definitely don’t want that.

There are some ideas floating around to fix this — such as forcing percentages/lengths to always be compared against the width — but that’s still a bit vague right now.

# Cascading

Another issue that was pointed out is one on Cascading. I especially like this one, as it gives us a good insight in how CSS behaves and works:

Inline conditionals will have the IACVT (Invalid At Computed Value Time) behavior that we have come to know and love (?) from Custom Properties. Since @if will desugar to inline conditionals, it will also fall back to that, which may sometimes be surprising. This means that these two snippets are not equivalent:

.notice {
	background: palegoldenrod;
}

.notice {
	/* Desugars to background: if(var(--warning) = on, orange, unset); */
	@if (var(--warning) = on) {
		background: orange;
	}
}
.notice {
	/* Desugars to background: if(var(--warning) = on, orange, palegoldenrod); */
	background: palegoldenrod;

	@if (var(--warning) = on) {
		background: orange;
	}
}

You can file IACVT (Invalid At Computed Value Time) in the #TIL section there.

A declaration can be invalid at computed-value […] if it uses a valid custom property, but the property value, after substituting its var() functions, is invalid. When this happens, the computed value of the property is either the property’s inherited value or its initial value […].

This explains why in the example below the background won’t be red but (the default) transparent.

:root { --not-a-color: 20px; }
p { background-color: red; }
p { background-color: var(--not-a-color); }

👉 As 20px is no valid <color> value, the last declaration will become background-color: initial;.

💡 If we would have written background-color: 20px directly (e.g. without the use of Custom Properties), then that declaration would have simply been discarded due to being invalid, and we would have ended up with a red background.

~

# In Closing

The “Higher Level Custom Properties” idea by Lea Verou is one that quite excites me, as it solves an actual issue one can have in their code and would avoid having to use one of the nasty hacks.

There’s still a long way to go before we might actually see this land, yet as the CSS WG has expressed interest I’m hopeful that the already identified issues will be wrinkled out, and that work on an official spec can start.

If you have your own input on this subject, then I suggest to participate in the Higher Level Custom Properties discussion on GitHub.

~

🔥 Like what you see? Want to stay in the loop? Here's how:

Native Aspect Ratio Boxes in CSS thanks to aspect-ratio


Old vs. New. Image by @una.

Back in May 2020 I was very delighted to read that the first Working Draft of the CSS Box Sizing Module Level 4 got published, as it featured an addition to CSS that I’ve been wanting for a long time now: native support for aspect ratio boxes through the new aspect-ratio CSS property.

With Chromium 89 (current Canary) and Firefox 85 (current Nightly) already supporting aspect-ratio unflagged, it’s time to start playing with this new addition and start thinking about dropping all those nasty hacks to mimic aspect ratios in CSS. Let’s take a look …

🤔 Working Draft (WD)?

The Working Draft (WD) phase is the first phase of the W3C Recommendation Track, and is considered the exploring phase of a W3C spec.

From thereon a spec can become a Candidate Recommendation (CR) to finally land on being a Recommendation (REC). In between those three stages there are two transition stages: Last Call Working Draft (LCWD) and Proposed Recommendation (PR)

In visual form, the Recommendation Track looks something like this:

See An Inside View of the CSS Working Group at W3C for more details on all phases.

⚠️ Note that the aspect-ratio has not shipped with all browsers yet. Here’s an overview of browser support that I’ll keep up-to-date:

~

~

# Welcome aspect-ratio

In short, the aspect-ratio property allows you to define a preferred aspect ratio on elements:

.box {
  width: 20vw;
  aspect-ratio: 16 / 9;
}

[CodePen Demo]

In the example above the .box will have a preferred aspect ratio of 16:9. Since its width is set to 20vw, the resulting height will be 20vw / 16 * 9 = 11.25vw. Easy, right?

~

# Allowed values for aspect-ratio

The value as set in the example above for aspect-ratio is of the <ratio> type:

  • It typically consists of two numbers separated by a /. The first parameter targets the width and the second one the height.
  • It’s also allowed to pass in just a single number. In that case the second number will be we considered to be 1. E.g. a <ratio> of 2 will translate to 2 / 1.
  • Passing in a 0 for either of the numbers is not allowed.
  • The spaces around the / are not required, so 2/1 is also a valid <ratio> value.

Another allowed value for the aspect-ratio property — which also is the default — is auto. This indicates that the box has no preferred aspect ratio and should size itself as it normally would.

🙋‍♂️ Hold up! How come images already behave correctly, without needing to define an aspect-ratio?

Images may be commonly used, but they are a quite uncommon type of HTML element:

  1. Images are replaced elements:

    A replaced element is an element whose content is outside the scope of the CSS formatting model, such as an image or embedded document. For example, the content of the HTML <img> element is often replaced by the image that its src attribute designates.

    Just check your DevTools: the browser will make an extra HTTP request for any image and fetch its contents separately. Once loaded, the browser will replace the original img tag with the actual image contents.

  2. Images have an intrinsic aspect ratio:

    The intrinsic dimensions represent a preferred or natural size of the object itself; that is, they are not a function of the context in which the object is used.

    Each photo that you take with your phone results in an image that has a certain width and height, which is referred to as the intrinsic or natural width/height. The intrinsic aspect ratio is the ratio between the intrinsic width and intrinsic height.

    When the browser has fetched the image and needs to draw it on screen it will take its intrinsic aspect ratio into account to know how big it should be drawn.

  3. When you define width and height attributes on an img, the browser will take those into account when drawing the image on screen. Nowadays browsers even internally map those properties to CSS sizing properties.

☝️ Do note that you can still set an aspect-ratio on an element that has an intrinsic aspect ratio. In that case your defined aspect-ratio will override the intrinsic aspect ratio.

~

# The fine print

# Aspect of what?

Depending upon which of width or height you set, the box dimensions will be calculated against that.

.box {
  width: 20vw;
  aspect-ratio: 16 / 9; /* Dimensions will be calculated against the width,
                           yielding a height of 11.25vw (20vw / 16 * 9) */
}
.box {
  height: 20vw;
  aspect-ratio: 16 / 9; /* Dimensions will be calculated against the height,
                           yielding a width of 35.55vw (20vw / 9 * 16) */
}

# aspect-ratio+width+height = 🚫

Setting an aspect-ratio won’t have effect on elements that have both a CSS width and CSS height set to a value other than auto. Only one of width or height can be explicitly set, and the other should remain set to auto.

.box {
  width: 20vw;
  height: 20vw;
  aspect-ratio: 16 / 9; /* won't have any effect! */
}

# aspect-ratio + percentage based width/height

In case one of the width and height should be set to a percentage based value such as 100%, the targeted box will take a look at the direct parent element’s dimensions to define its value upon that.

.parent {
  height: 100px;
}
.parent .box {
  height: 100%;
  aspect-ratio: 1 / 1; /* .box will be 100px by 100px */
}

There’s some more edge cases here too, but let’s not get too deep into the spec 😉

# aspect-ratio sets a preferred aspect ratio

Setting an aspect-ratio will tell the browser that this is a preferred aspect ratio. Should the content of the box be larger, then the box will simply grow.

div {
  aspect-ratio: 1/1;
  /* 'width' and 'height' both default to 'auto' */
}
+----------+  +----------+  +----------+
| ~~~~~~~~ |  | ~~~~~~~~ |  | ~~~~~~~~ |
| ~~~~~~~~ |  | ~~~~~~~~ |  | ~~~~~~~~ |
| ~~~~~~~  |  | ~~~~~~~~ |  | ~~~~~~~~ |
|          |  | ~~~      |  | ~~~~~~~~ |
+----------+  +----------+  | ~~~~~~~~ |
                            | ~~~~~~   |
                            +----------+

To maintain the aspect-ratio, you can set overflow to auto so that a scrollbar will be shown should the contents be larger:

div {
  overflow: auto;
  aspect-ratio: 1/1;
}
+----------+  +----------+  +----------+
| ~~~~~~~~ |  | ~~~~~~~~ |  | ~~~~~~~~^|
| ~~~~~~~~ |  | ~~~~~~~~ |  | ~~~~~~~~ |
| ~~~~~~~  |  | ~~~~~~~~ |  | ~~~~~~~~ |
|          |  | ~~~      |  | ~~~~~~~~v|
+----------+  +----------+  +----------+

What also works, is setting min-height

Overriding the min-height property also maintains the 1:1 aspect ratio, but will result in content overflowing the box if it is not otherwise handled.

div {
  aspect-ratio: 1/1;
  min-height: 0;
}
+----------+  +----------+  +----------+
| ~~~~~~~~ |  | ~~~~~~~~ |  | ~~~~~~~~ |
| ~~~~~~~~ |  | ~~~~~~~~ |  | ~~~~~~~~ |
| ~~~~~~~  |  | ~~~~~~~~ |  | ~~~~~~~~ |
|          |  | ~~~      |  | ~~~~~~~~ |
+----------+  +----------+  +-~~~~~~~~-+
                              ~~~~~~    

~

# Demos

# Using aspect-ratio with a fallback for older browsers

Thanks to the powerful @supports it’s possible to add a fallback for browsers that don’t support aspect-ratio. In the demo below (based upon this demo by Una) a fallback using the padding-top hack is applied:

[CodePen Demo]

# Using aspect-ratio with CSS Custom Properties

By introducing a CSS Custom Property it’s possible to make your code more generic and extract away a .aspect-ratio class.

[CodePen Demo]

To use it, add apply the .aspect-ratio on the element you want, and pass in a --aspect-ratio CSS Custom Property:

<div
  class="aspect-ratio"
  style="--aspect-ratio: 16/9;"
>I am an aspect ratio box</div>

The code is written so that it will use the value for --aspect-ratio in both the fallback and the modern version.

# Automatically setting aspect-ratio on iframes and the like

When you embed an iframe you most likely set its width and height HTML attribute.

<iframe
  src="https://www.youtube.com/embed/e7BkmF8CJpQ"
  width="560"
  height="315"></iframe>

It’s possible to use the values of these attributes to automatically set the aspect-ratio.

iframe[width][height] {
  aspect-ratio: attr(width) / attr(height);
}

Heck, you could even target [width][height] if you’d want!

💁‍♂️ FYI: This is also what browsers nowadays do for images: they map the values from the width and height HTML attributes from images to a aspect-ratio in order to prevent Cumulative Layout Shift.

Firefox’s internal stylesheet for example looks like this:

img, input[type="image"], video, embed, iframe, marquee, object, table {
  aspect-ratio: attr(width) / attr(height);
}

marquee, lol 😆

[CodePen Demo]

🐛 I’ve noticed that reading the width/height attribute values using attr() to pass them into aspect-ratio doesn’t seem to work in current Chromium. To cater for that I’m also passing their values by means of a CSS Custom Property …

<iframe
  src="https://www.youtube.com/embed/e7BkmF8CJpQ"
  width="560"
  height="315"
  style="--aspect-ratio: 560 / 315"
></iframe>
🙋‍♂️ Why doesn’t this iframe demo have a padding-top fallback injected using :after?

Just like images, iframes also are replaced elements. It’s not possible to inject contents using :before/:after on replaced elements.

If you really need to have a fallback, you need to wrap the iframe in a container and apply the aspect-ratio on the container. See Embed Responsively and adjust were needed.

~

# In Closing

After 8 years of wanting this feature to land in CSS (ref) I’m very happy to see this addition make it into the spec. It’s still a Working Draft right now, but that doesn’t stop me from being excited about it already. I hope you are too 🙂

🔥 Like what you see? Want to stay in the loop? Here's how:

Container Queries are coming to Chromium!

Update 2021-02-12: Thanks to the hard work by Miriam Suzanne and others this proposal is now officially part of the CSS Specification Process (ref) and set to be part of css-contain-3 … it’s happening, people! 🎉

Update 2021-03-28: A first iteration of this implementation has landed in Chrome 91 … let’s take a closer look and build a demo!

Just announced on the Chromium mailing list is an “Intent to Prototype” Container Queries, which is quite exciting news I must say!

🤔 Container Queries?

Container Queries allow authors to style elements according to the size of a container. This is similar to a @media query, except that it evaluates against a container instead of the viewport.

The experimental implementation will follow Miriam Suzanne’s proposal, which looks like this:

/* (1) Create an implicit "container root" */
main,
aside {
  contain: size;
}

.media-object {
  display: grid;
  gap: 1em;
}

/* (2) Container Query targeting the nearest 
   "container root" ancestor. The rules nested
   inside will only be applied if the "container
   root" has a max-width of 45em */
@container (max-width: 45em) {
  .media-object {
    grid-template: 'img content' auto / auto 1fr;
  }
}

Applying contain: size; (1) onto an element will make it an implicit “container root” or “containment context”. Elements contained inside it can then have container queries applied onto them, by use of a new at-rule @container (<container-media-query>) (2). The target selector and CSS rules to apply in that case are — similar to what we do with “regular” media queries — nested within the @container at-rule.

In the example above extra rules will be applied to .media-object whenever its nearest “container root” ancestor — such as <main> or <aside> — has a max-width of 45em.

🧑‍🔬 This proposal is experimental and has not been approved by the CSSWG yet. The expressed “intent to prototype” is meant as an experiment to see whether this idea would be worth pursuing or not. In the end, it could be that the final syntax can differ from the one listed here, if the proposal is workable in the first place.

~

A previous version of this proposal by L. David Baron required a context selector to be set, but that has been dropped here. The @container rule from Miriam’s version will work in any containment context (read: the nearest parent element that has contain: size set). The syntax might still change, but that’s irrelevant to the prototype which is to be implemented:

This is not at all finalized, but the underlying problems we need to solve in Blink are (mostly) the same regardless of how the feature is accessed, so we’ll for now use this proposal as the temporary syntax.

~

Intent to Prototype: Container Queries →
Chrome Tracking Bug →

🔥 Like what you see? Want to stay in the loop? Here's how:

CSS leading-trim – The Future of Digital Typesetting

Ethan Wang, who works at Microsoft:

In a standard text box, there’s almost always extra space above and below the actual text. Because of this, when you use a text box to measure and implement spacing, it ends up larger than you intended. The bigger the line height, the bigger the problem.

You can see the issue below: the 32px gap between all text ends up too be more than 32px because of this:

To solve this the new CSS property leading-trim from the CSS Inline Layout Module Level 3 specification can be used. As per spec:

The leading-trim properties allow controlling the spacing above and below the first and last lines of a block. It allows precise control over spacing; moreover, by relying on font metrics rather than hard-coded lengths, it allows content to be resized, rewrapped, and rendered in a variety of fonts while maintaining that spacing.

Vertical Centering of Text is explicitly mentioned in the specification of this new property. Use it as follows:

h1 { 
 text-edge: cap alphabetic;
 leading-trim: both;
}

As Wang notes:

The example above first uses text-edge (also a new property) to tell the browser the desired edge of the text is the cap height and the alphabetic baseline. Then it uses leading-trim to trim it from both sides.

These two simple lines of CSS create a clean text box that hugs your text. This helps you achieve much more accurate spacings and create a better visual hierarchy.

Cool! 🤩

Leading-Trim: The Future of Digital Typesetting →

Colors in CSS: Hello Space-Separated Functional Color Notations

In CCS there are several ways to notate color values. Basically we have three options:

  1. Using a Keyword: e.g. black
  2. Hexadecimal Notation: e.g. #000000, #000, and #000000FF
  3. Functional Notation: e.g. rgb(0, 0, 0), rgba(0, 0, 0, 1) (and the hsl()/hsla() variants)

~

The CSS Color Module Level 4 specification adds an extra variation to the Functional Notation: it allows you to separate the individual RGB (or HSL, etc.) values using spaces instead of commas, e.g. rgb(0 0 0);.

rgb(), rgba(), hsl(), and hsla() have all gained a new syntax consisting of space-separated arguments and an optional slash-separated opacity.

Above that rgba() has become an alias for rgb(), so our colors in CSS – using the Functional Notation – will soon look as follows:

/* Before: Comma-Separated Functional Color Notation */
div {
  color: rgb(0, 0, 0);
  color: rgba(0, 0, 0, 1);
}

/* After: Space-Separated Functional Color Notation */
div {
  color: rgb(0 0 0);
  color: rgb(0 0 0 / 1);
  color: rgb(0 0 0 / 100%);
}

~

You should start writing your colors using this new syntax today, as:

  1. The syntax is already supported by all major browsers.
  2. New color functions – such as lab(), lch() and color() – will only support the space-separated syntax

Your preprocessor can take care of older browsers such as IE11 😉

~

Did this help you out? Like what you see?
Thank me with a coffee.

I don't do this for profit but a small one-time donation would surely put a smile on my face. Thanks!

☕️ Buy me a Coffee (€3)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.

Firefox 72: Individual CSS Transform Properties

New in Firefox 72 is the ability to individually define CSS Transform Properties. You can now separately define scale, rotate, and translate CSS properties, instead of having to chuff them all into one single transform property.

The translate, rotate, and scale properties allow authors to specify simple transforms independently, in a way that maps to typical user interface usage, rather than having to remember the order in transform that keeps the actions of transform(), rotate() and scale() independent and acting in screen coordinates.

element {
  scale: 2;
  rotate: 30deg;
  translate: -50% -50%;
}

The order that they are applied is, as per CSS Transforms Level 2 spec, first translate, then rotate, and then scale — not the order which you define them in.

By having individual transform props, this also means that we can animate and transition them separately.

@keyframes individual {
  50% {
    translate: 0 50%;
  }
  75% {
    scale: 1;
  }
}
element {
  transition:
    rotate 200ms ease-in-out,
    scale 500ms linear;
}

element:hover {
  scale: 2;
  rotate: -3deg;
}

Here’s a pen demonstrating its usage:

See the Pen
Individual CSS Transform Properties Demo
by Bramus (@bramus)
on CodePen.

In Chrome these new props are still behind the #enable-experimental-web-platform-features feature flag.

~

Did this help you out? Like what you see?
Thank me with a coffee.

I don't do this for profit but a small one-time donation would surely put a smile on my face. Thanks!

☕️ Buy me a Coffee (€3)

To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.