I spend an unhealthy amount of time on the typography in my designs, and if you’ve read any traditional typography books, you might remember “the measure.” If not, it’s simply the length of a line of text. But measure means more than that, and once you understand what it represents, it can change how you think about layout entirely.
Before desktop publishing, typesetters worked with physical metal type. They set lines of text within a composing stick, and the width of the stick was called the measure. It was literally the space you could fit type into, and everything else on the page — from column widths to margins and gutters — was designed around it.
A good measure makes reading text comfortable, while a bad one makes it more difficult.
And when the measure is correct, the rest of the layout falls into place. So, rather than allowing layout to dictate the measure, doesn’t it make more sense for the measure to inform layout decisions?
Turning the measure into a custom property
In my CSS, I start by defining a custom property:
:root {
--measure: 60ch;
}
ch units are ideal for this because they relate to the width of the zero (0) character in a chosen font. Somewhere between 60–70 characters per line is a comfortable reading length, so 65 characters feels natural.
60ch is a solid starting point, but it isn’t universal. Different typefaces produce different real-world line lengths, even when the character count stays the same. Typefaces with a large x-height appear visually larger, so 60ch can feel longer than it really is. Condensed faces make 60ch feel too short. Wider faces can make it feel too long. Even small tracking changes stretch or compress the perceived measure.
The point is simple: treat 60ch as a baseline, then adjust by eye. Once the measure becomes a variable, you can use it across your layout to keep everything connected.
Keeping text readable
I suspect most developers have used the max-width property. Although it took time to retrain my muscle memory, I now use CSS logical properties, replacing max-width with max-inline-size:
That prevents long-line syndrome, which afflicts too many websites when viewed on large screens.
Designing multi-column text
Multi-column layout is one of the most underrated CSS layout tools. Instead of specifying the number of columns, defining the column width lets the browser decide how many columns fit along the inline axis. The measure can also define the widths of those columns:
When the columns’ parent container becomes wide enough, a browser flows text into as many readable columns as will fit. When there’s not enough space for multiple columns, the layout falls back to a single column, all without breakpoints and media queries.
Letting the measure influence a grid
The measure also helps me design grids which feel connected to my content. For example, when I’m implementing a layout with a column containing long-form content, plus another flexible column, I can lock the text content to the measure:
That first column ensures that reading text remains comfortable, while the second adjusts to whatever horizontal space remains.
Measure-driven container queries
For years, developers learned to think of screen sizes in terms of specific breakpoints (320px, 48em, 64em, and so on) and media queries. It’s a hard habit to break and one which, to be honest, I haven’t always managed myself.
Those numbers don’t come from content; devices define them. They really should be called “guesspoints” because we mostly never know which numbers work for most people. Instead of dictating that a layout switches at, say, 48em and 64em, I can let my content decide when a layout should change by using the measure.
Measure makes a better breakpoint. A measure-driven breakpoint, combined with container queries, responds to text content. So, when a column becomes narrower than a readable line length, a layout can collapse. But when it becomes wide enough to support more structure, the layout can expand.
A CSS container query checks the width of the component’s container instead of a screen or the viewport. For instance, when a component is narrower than 65 characters, I can apply specific styles:
/* When the container is no wider than the --measure */
@container (max-width: var(--measure)) {
/* Styles */
}
My design might include multiple columns, perhaps a wider column for the main content and a narrower one for supporting information:
This feels more natural because the composition is connected to the content rather than the device’s width, creating a comfortable reading environment.
Making a measure system
Depending on the variety of content I need to present, on certain projects, I define several variations of the measure:
This gives me a variety of line lengths for use in different situations:
Small for captions and other shorter text blocks
Regular for body copy
Large for introductions, headings, and hero sections
When type, spacing, and layout all share the same underlying rhythm, the result can feel more coherent and more intentional.
Considering the measure can change how you design
When you design with the measure in mind, layout stops being guesswork and becomes a conversation between content and composition. Reading becomes more comfortable, and the page feels more deliberate. It’s a small shift, but once you start anchoring your design decisions to the measure, it changes how you approach layout entirely.
Do you think of scrolling as a more modern way of reading than turning pages in a book? Nope, the concept originated in ancient Egypt, and it’s older than what we now classify as books. It’s based on how our ancestors read ancient physical scrolls, the earliest form of editable text in the history of writing. I am Jewish, so I remember my earliest non-digital scrolling experience was horizontally scrolling the Torah, which can be more immersive than traditionally scrolling a webpage. The physical actions to navigate texts have captured the imagination of many a storyteller, leading authors to gamify the act of turning pages and to create stories that incorporate the physical actions of opening a book and turning pages as part of the narrative. However, innovative experiences using non-standard scrolling haven’t been explored as thoroughly.
I can sympathize with those who dismiss scrollytelling as a gimmick: it can be an annoyance if it’s just for the sake of cleverness, but my favorite examples I’ve seen over the years tell stories we couldn’t otherwise. There’s something uniquely immersive about stories driven by a mechanic that has lived in our species’ collective muscle memory since ancient days.
Still unconvinced of the value of scrollytelling? Alright, hypothetical annoying skeptic, let’s first warm up with some common use cases for scroll-based styling.
Popular scroll-based designs we can simplify with modern CSS
It’s awesome that Chrome has solid support for native scroll-driven animations without requiring JavaScript, and we see that both Safari and Firefox are actively working on support for the new scroll-driven standards. These new features facilitate optimized, smooth scroll-driven animations. The support via pure CSS syntax makes scroll-driven animation a more approachable option for designers who may be more comfortable with CSS than with the equivalent JavaScript.
Indeed, even though I am a full-stack developer who is supposed to know everything, I found having scroll-driven animation built into the browser and available with a few lines of CSS gets my creativity flowing, inspiring me to experiment more than if I had to go through hoops of a proprietary library and writing JavaScript, which in the past might include messing with intersection observer and fiddly code.
If animation timelines weren’t enough, Chrome has now introduced support for CSS carousel, scroll-initial-target, and scroll-state queries—all of which provide opportunities to control scrolling behaviors in CSS and style all the things based on scrolling.
In my opinion, scroll-state is more of an evolutionary than revolutionary addition to the growing range of scroll-related CSS features. Animation timelines are so powerful that they can be hacked to achieve many of the same effects we can implement with scroll-state queries. Therefore, think of scroll-state as a highly convenient, simplified subset of what we can do in more verbose hacky ways with animation timelines and/or view timelines.
Some examples of effects scroll-state simplifies are:
Take a moment to appreciate that Bramus is from the future, and to reflect on how scroll-state can simplify common UI patterns, such as scroll shadows, which Chris Coyier said might be his “favorite CSS trick of all time.” This year, Kevin Hamer showed how scroll-timeline can achieve scroll shadows in CSS with fewer tricks. It’s excellent, but the only thing better than clever CSS tricks is that scroll shadows no longer require a trick at all. Hacking CSS is fun, but there is something to be said for that warm fuzzy feeling that CSS was made just for your use case. This demo from the Chrome blog shows how scroll shadows and other visual affordances are easy to implement with scroll-state.
But the popularity of Kevin’s article suggests that normal, sane people will gravitate to practical use cases for the new CSS scroll-based features. In fact, a normal and sane author might end the article here. Unfortunately, as I revealed in a previous article, I have been cursed by a spooky shopkeeper who sells CSS tricks at a haunted carnival, so I now roam the earth attempting the unthinkable with pure CSS.
Decision time
As you reach this paragraph in the article, you realize that when you scroll, it fast-forwards reality. Therefore, after we end the discussion of scroll shadows, the shadows swallow the world outside your window, except for two glowing words hovering near your house: CSS TRICKS. You wander out through your front door and meet a street vendor standing beneath the neon sign. The letters give her multiple shadows as if she has thrown them down like discarded masks, undecided about which shade of night to wear. On the table before her lies a weathered scroll. It unrolls on its own, whispering misremembered fragments from a forgotten CSS-Tricks article: “A scroll trigger is a point of no return, like a trap sprung once the hapless user scrolls past a certain point.”
The neon flickers like a glitch, revealing another of the shopkeeper’s faces: a fire demon doppleganger of yourself who is the villain of the CodePen we’ll descend into if you scroll further.
“Will you continue?” the fire demon hisses. “Will you scroll deeper into the madness at the far edges of CSS?
Non-linear scrollytelling
Evidently, you are game to play with fire, so check out the pure CSS experiment below, which demonstrates a technique I call “nonlinear scrollytelling,” in which the user controls the outcome of a visual story by deciding which direction to scroll next. It’s a scrolling Choose Your Own Adventure. But if your browser is less adventurous than you are, watch the screen recording instead. The experiment will only work on Chromium-based browsers for now, because it relies on scroll-state, animation-timeline, scroll-initial-target and CSS inline conditionals.
I haven’t seen this technique in the wild, so let me know in the comments if you have seen other examples of the idea. For now, I’ll claim credit for pioneering the mechanics — but I give credit to the talented Dead Revolver for creating the awesome, affordable pixel art bundle I used for most of the graphics. The animated lightsaber icon was ripped from this cool CodePen by Ujjawal Anand, and I used ChatGPT to draw the climbable building. To make the bad guy, I reused the same spritesheet from the player character, but I implemented the Mirror Match trope from Mortal Kombat, using color shifting to create a “new” character who I evilized by casting the following spell in CSS:
.evil-twin {
transform: rotateY(180deg);
filter: invert(24%) sepia(99%) saturate(5431%) hue-rotate(354deg) brightness(93%) contrast(122%);
background-image: url(/* same spritesheet as the player character */);
}
It’s cool that CSS helps recycle existing assets for those like me who are drawing-challenged. I also wanted to make sure that well-supported CSS features like transform and filter didn’t feel left out of the fun in an experiment filled with newer, emergent CSS features.
But if you’ve come this far, you’re probably eager to understand the scroll-related CSS logic.
Our story begins in the middle of the end
You may have noticed our experiment earns extra crazy points as soon as it loads, by starting at the middle of the bottom of the page so that the player can choose whether to scroll left to run away, or scroll right to walk unarmed towards the bad guy if the player wants to compete with the madness level of the game’s creator.
This explainer for the emergent scroll-initial-target property shows that controlling scroll position on load was previously possible by hacking CSS animations and the scroll-snap-align property. However, similar to what we discussed above about the value proposition of scroll-state, a feature like scroll-initial-target is exciting because it simplifies something that previously required verbose, fragile hacks, which can now be replaced with more succinct and reliable CSS:
As cool as this is, we should only subvert expectations for how a webpage behaves if we have a sufficient reason. For instance, CSS like the above could have simplified my pure CSS swiper experiment, but Chrome only added scroll-initial-target in February 2025, the month after I wrote that article. Using scroll-initial-target would be justified in the swiper scenario, since the crux of that design was that the user started in the middle with the option to swipe left or right.
A similar dilemma is central to the opening of our scrollytelling narrative. The disorienting experience of finding ourselves in an unexpected scroll position with only the option to scroll horizontally heightens the drama, as the user has to adapt to an unusual way of interacting while the bad guy rapidly approaches. I’m feeling generous, so let’s give the user 20 seconds to figure it out, but you can experiment with different timeframes by editing the --chase-time custom property at the top of the source file.
We’re going to create a CSS implementation of the slasher movie trope in which a walking aggressor can’t be outrun. We do that by marking the bad guy as position: fixed, then adding an infinite walk-cycle animation and another animation that moves him relentlessly from right to left across the screen. Meanwhile, we give the player character a running animation and position him based on a horizontal animation timeline. He can run, but he can’t hide.
body {
.idle {
animation: idleAnim 1s steps(6) infinite;
}
/* --scroll-direction is populated using the clever property Bramus demonstrates
here https://www.bram.us/2023/10/23/css-scroll-detection */
.sprite {
transform: rotateY(calc(1deg * min(0, var(--scroll-direction) * 180)));
}
@container not style(--scroll-direction: 0) {
.sprite {
animation: runAnim 0.8s steps(8) infinite;
}
}
.evil-twin-wrapper {
position: fixed;
bottom: 5px;
z-index: 1000;
margin-left: var(--enemy-x-offset);
/* we'll explain later how we detect the way the game should end */
--follow: if(style(--game-state: ending): paused; else: running);
animation: var(--chase-time) forwards linear evil-twin-chase var(--follow);
}
}
He can’t hide, but we’ll next introduce a second scroll-based decision point using scroll-state to detect when our hero has been backed into a corner and see if we can help him.
How scroll-state could save your life
As our hero runs away to the left, the buildings and sky in the cityscape background show off a few layers of parallax scrolling by assigning each layer an anonymous animation timeline and an animation that moves each layer faster than the layer behind it.
.sky, .buildings-back, .buildings-mid, .sky-vertical, .buildings-back-vertical, .buildings-mid-vertical {
position: fixed;
top: 0;
left: 0;
width: 800%;
height: max(100vh, 300px);
background-size: auto max(100vh, 300px);
background-repeat: repeat-x;
animation-timing-function: linear;
animation-timeline: scroll(x);
}
/*...repetitively assign the corresponding animations to each layer...*/
@keyframes move-sky {
from {
transform: translateX(0);
}
to {
transform: translateX(-2.5%);
}
}
@keyframes move-back {
from {
transform: translateX(0);
}
to {
transform: translateX(-6.25%);
}
}
@keyframes move-mid {
from {
transform: translateX(0);
}
to {
transform: translateX(-12.5%);
}
}
This usage of animation timelines is what they were designed for, which is why the code is straightforward. If we had to, we could push the boundaries and use the same technique to set a Houdini variable in an animation timeline to detect when the player reaches the left corner of the screen — but thanks to scroll-state queries, we have a cleaner option.
@container scroll-state((scrollable: left)) {
body {
overflow-y: hidden;
}
}
@container scroll-state((scrollable: bottom)) {
body {
width: 0;
}
}
That’s all we need to toggle vertical and horizontal scrolling based on position! This is the basis that allows the player to escape from being slashed by the bad guy. Now we can scroll up and down to climb the ladder only when the player reaches the left corner where the ladder is, and disallow horizontal scrolling while he is climbing.
I could have made the game detect reaching the left of the screen using animation timelines, but that would involve custom property toggles, which are more verbose and error-prone.
When the player climbs to the top of the ladder to collect the lightsaber, we do need one toggle property so the game will remember we have collected the weapon, but it’s simpler than if we had used animation timelines.
Contrariwise, the animation cycle while the sprite is climbing the ladder is a job for animation-timeline used to assign an anonymous vertical timeline to the player sprite. This is applied conditionally when our scroll-state query detects that the player is between the bottom and the top of the ladder. It’s a nice example of how animation timelines and scroll-state queries are good at different things, and work well together.
We apply the techniques I discovered in my CSS collision detection article to detect when the two characters meet for their showdown. At that point, we want to disable scrolling entirely and display the appropriate non-interactive endgame cutscene depending on the choices our user made. Notice that if we detect the good guy won, he only strikes with the sword once, whereas the bad guy will continue to slash infinitely, even after the good guy is dead. What can I say — I was working on this CodePen around Halloween.
In the past, I wrote an article questioning the need for inline CSS conditionals — but now that they’ve landed in Chrome, I find them addictive, especially when creating a heavily conditional CSS experiment like nonlinear scrollytelling. I like to imagine that the new if() function stands for Interactive Fiction. Below is how I detect the endgame conditions and choose which animations to play in the final cutscene. I am not sure of the most readable way to space out if() code in CSS, so feel free to start holy wars on that topic in the comments.
Should we non-linearly scrollytell all the things?
I am glad you asked, hypothetical troll who wrote that heading. Of course, even putting the technical challenges aside, you know that this won’t always be the right approach for a website. As Andy Clarke recently pointed out here on CSS-Tricks, design is storytelling. The needs of every story are different, but I found my little pixel art guy’s emotional story arc requires non-linear scrollytelling.
I think this particular example isn’t a gimmick and is a legitimate form of web design expression. The demo tells a simple story, but my wife pointed out that a personal situation I am dealing with has strong analogies to the pixel guy’s journey. He finds himself in a situation where the only sane option is to allow himself to be backed into a corner, but when all seems lost, he finds a way to rise above the adversity. Then he learns that the moral high ground is its own form of trap, so he must put his own spin on the wisdom of Sun Tzu that “to know your enemy, you must become your enemy.” He apparently lowers himself back to the aggressor’s level — but he only does what is necessary. The bitterwseet moral is that survival sometimes requires taking a leaf out of the enemy’s book — but the user has been guiding the hero through this story, which helps the audience to understand that the good guy’s motivations are not comparable to those of his adversary. While testing the CodePen, I found the story moving and even suspenseful in an 8-bit nostalgia kind of way, even if some of that suspense was my uncertainty about whether I would get it working.
From a technical point of view, I think building a full-scale website based on this idea would require a mix of CSS and JavaScript, because storing state in CSS currently requires hacks (like this one, which is cool but also highly experimental). The paused animation approach to remember that the player collected the sword can glitch due to timer drift, so there is a small chance the dude will start the game with the lightsaber already in his hand! If you resize the window during the endgame, you can glitch the game, and then things get really weird. By contrast, something like the scroll snap events — already supported in Chrome — would allow us to store state and even play sounds using a script that fires based on scroll interactions.
It seems like we already have enough in CSS to build a site like this one, which uses horizontal multimedia scrollytelling to raise awareness that interpersonal violence exists on a continuum and tends to escalate if the target is unable to recognize the early warning signs. That’s a worthy topic I unfortunately have some experience with, and the usage of horizontal scrollytelling to address it demonstrates that a wide variety of stories can be told engagingly through scrollytelling.
I leave to the various futures (not to all) my garden of forking paths.
Chrome 144 features a small change to overscroll-behavior: it now also works on non-scrollable scroll containers. While this change might seem trivial, it fixes an issue developers have been dealing with for ages: prevent a page from scrolling while a (modal)<dialog> is open.
The problem is that the dialog itself is not a scroll container. If it was, we could slap overscroll-behavior: contain on it and be done with it. Brad demoed his solution that involved a JavaScript-y approach that sets the <body> to fixed positioning when the dialog is in an open state:
That’s the tweak Bramus is talking about. In Chrome 144, that’s no longer the case. Going back to that first demo, we can do a couple of things to avoid all the JS mumbo-jumbo.
First, we declare overscroll-behavior on both the dialog element and the backdrop and set it to contain:
body {
overscroll-behavior: contain;
}
#dialog {
overscroll-behavior: contain;
}
You’d think that would do it, but there’s a super key final step. That dialog needs to be a scroll container, which we can do explicitly: