In this article, I present my new technique for solving a CSS problem that was deemed impossible — true shrinkwrapping of an element with auto-wrapped content. By using anchor positioning and scroll-driven animations, we can adjust our element’s outer dimensions by measuring its inner contents, demonstrating that for many cases this can already work and might unlock a future native feature.
CSS is Awesome
CSS is Awesome
Since anchor positioning and scroll-driven animations appeared on my radar, I knew they would unlock things that were not possible before. These new CSS features hook into many things that were previously either impossible or available only through cumbersome JavaScript APIs. Two years ago, I wrote about one of such things — the “shrinkwrap” problem and a partial decorative workaround that used anchor positioning — in my “The Shrinkwrap Problem: Possible Future Solutions” article.
After writing that article, and experimenting more with scroll-driven animations, I knew that there could be a way to combine those and achieve shrinkwrapping not just visually, but also make it affect the layout. In the last few months, I was experimenting with my past findings and a few novel approaches, and, finally, honed them into something reusable — and already working in stable Chrome and Safari, with a graceful degradationGo to a sidenote for other browsers.
Disclaimeranchor
That said, even though my examples for the base technique might work in the latest versions of some stable browsers, the technique itself is highly experimental.
For example, I experienced occasional crashes in Safari. I managed to find a workaround, but I strongly suggest being careful before using anything from this article in production.
The Problemanchor
Let’s say we have a simple header, which we style to look like a nice rounded box, and that should shrink to its max-content:
I am a Short Header!
When it is short, all is good! But we anticipate that it might wrap when the content is longer, and add text-wrap: balance to make it prettier. But then, this happens:
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
On most viewports, you could see how when the header wraps, we get all this extra space on one side — the shrinkwrap problem. What exactly is happening, and why is it so challenging to make it work?
I’ll quote my first article about shrinkwrap:
When different content wraps — be it text, floats, inline-blocks, flex, or grid, — if that wrapping is automatic (without hard breaks), the way CSS calculates the final width is limited. The element with wrapped items gets expanded to fill all the available space.
In CSS2 specs, this behavior is called “shrink-to-fit”:
shrink-to-fit width is: min(max(preferred minimum width, available width), preferred width)
This is a problem people have stumbled over and over from the beginning of time.
Under a cut — a long (not comprehensive) list with many links!
-
In 2015, Elika J. Etemad wrote about it to the www-style mailing list: “True Shrinkwrapping”. She even already mentioned
text-wrap: balancein the code example! -
In 2016, yisibl opened an issue #191 on CSSWG’s GitHub, “How to shrink to fit the width?”, showing a use case for wrapping of items inside a flexbox layout. Most of the following cases are from this issue.
-
In 2017, Vasilis van Gemert questioned if this can be fixed in his blog post “How do I fix this CSS alignment issue?”, about a left-aligned block that, when wrapped, should be aligned as a whole to the right.
-
In 2018, Nadya678 opened a duplicate issue about this.
-
Also in 2018, Benoît Rouleau provided another use case in the issue #191. The CodePen in question is no longer accessible, but from the description, it sounds like the underlined headers use case from my previous article about shrinkwrap.
-
In 2019, Dan Tonon also provided his use case in the same issue. This is probably the hardest to solve use case — a menu.
-
In 2020, Dan stumbled upon this again and opened a duplicate issue with the same case.
-
Starting from 2023, we saw a resurgence of reports about this issue thanks to the work on
text-wrap: balancewhich highlighted this issue. Many people did write about it then:- On March 3, Adam Argyle wrote about a few use cases.
- On September 7, Šime Vidas linked to a related Reddit post, which had a “message” use case.
- I published my “The Shrinkwrap Problem: Possible Future Solutions” article in December.
-
In 2024 this continued:
- On April 23, Lea Verou highlighted the
text-wrap: balanceissue once again. - On May 3, Vincent Rubinetti linked to a JS workaround on StackOverflow.
- On May 19, Tomer Aberbach wanted to replace a JS lib with
text-wrap: balanceand couldn’t because of this issue.
- On April 23, Lea Verou highlighted the
-
2025!
- On April 18, Miriam Suzanne stumbled upon the
text-wrap: balanceuse case as well. - On May 21, Mo Beigi ran into one of the original use cases for the menu.
- On May 27, Stephanie Eckles mentioned a tooltip use case they had in Adobe, one that we have as well in Datadog.
- On July 21, Lea returned to this problem, opening a new duplicate issue with a proposal to use
fit-contentor something similar for this. - On October 23, Svein Alexander Frotjold provided another use case with alignment of form fields.
- On April 18, Miriam Suzanne stumbled upon the
And this is just mostly mentions from the #191 issue — there are also many StackOverflow issues and likely other places where authors wrote about this.
This article will show how we could solve almost all of these cases.
Solutionsanchor
The bulk of all use cases are “simple”: we have a pre-defined space in which we have our element that could wrap, with the wrapped state often being the default one. There, we never want to use max-content for the element’s width, and these elements generally do not depend on the surrounding context but want just their box to be flush with text or the surrounding context to depend on our wrapped box.
These cases are solved either by the base technique, or by a more advanced version of it where we have to measure multiple items.
The hardest cases are those like the menu items, where every item might wrap, and by default, they want to be sized as max-content. I admit that my technique is not a good fit for these cases, but I will attempt to solve that as well, although through an extension of the base technique that uses content duplication.
The Base Techniqueanchor
Here is how I will present the technique:
-
I will list the limitations and corresponding prerequisites for using this basic technique.
-
The full code for the abstracted technique is placed under a cut — if you are eager to try to understand what is going on inside without my lengthier explanations, feel free to read it! Although I placed many comments inside, which helps.
-
Not all of that code is needed for the simplest of cases: first, I will iteratively explain how we solve the common case of an element with
text-align: left. -
Then, I will complete the technique by handling the non-left text alignment.
-
Finally, I will show how to use the technique as a building block for handling more complex cases with multiple nested phrasing contents.
Limitations & Requirementsanchor
The prerequisites for being able to use this technique are:
-
Generally, we’d want to have some
container-type: inline-sizearound our element, as its default max-inline-size will use100cqi, and most use cases will want to usecqiin one way or another. -
The element’s
max-inline-sizeshould not dependGo to a sidenote on its siblings. For the technique to work, we will need to set it to a value inpx, which could depend on its container through the container query length units. But we can’t have our element respond toflex-shrinkorflex-grow: when placed in a flex or grid context, its width will be more or less static. -
Our element must have only phrasing content, or, in other words, should contain only inline elements inside. Replaced elements like
<img />are allowed inside, alongside anything withinline-block,inline-flex, and otherinline-s, like the futureinline grid-lanes.
Full Codeanchor
If you’re curious, you can peek at the full code of the technique. It is thoroughly commented and might cover some of the things I did not cover in this article. That said, I tried to expand on many things in later sections, so keep reading if you want to get the most of that info in a more readable form!
The full code of the technique.
/*
The shrinkwrap technique.
https://kizu.dev/shrinkwrap-solution/
*/
.shrinkwrap {
/*
This should be easy to override, so placing the
custom property defaults in a separate layer.
*/
@layer defaults {
/*
We cannot use `%` for either; use `cqi` instead.
Ideally, `progress()` would allow us to do it:
https://github.com/w3c/csswg-drafts/issues/13315
*/
--sw-limit: 100cqi;
--sw-padding: 0px;
--sw-inner-padding: 0px;
--sw-inset: initial;
--sw-source: initial;
/*
Cyclic toggles, allowing turning the technique
on and off. See https://kizu.dev/cyclic-toggles/
*/
--sw-enabled: var(--sw-enabled--on);
--sw-enabled--on: var(--sw-enabled,);
--sw-enabled--off: var(--sw-enabled,);
/*
Override the toggle when the timeline-scope is not
supported, which will disable everything nicely.
*/
@supports not (timeline-scope: --f) {
--sw-enabled: var(--sw-enabled--off) !important;
}
}
/* Deriving an inner value to include the paddings. */
--_sw-limit: calc(
var(--sw-limit)
-
2 * var(--sw-padding)
);
/*
Must be `block`, `flow-root`, etc, so could be nested
in a flexbox/grid, but can’t establish one itself.
*/
display: block;
/*
The content part will likely overflow due to the
inner box being larger.
*/
overflow: hidden;
/* Lifting the scope of view timelines from inside. */
timeline-scope: --_sw-x;
/*
Accessing the start and end coordinates of the
inner element via scroll-driven animations.
*/
animation: var(--sw-enabled--on,
--_sw-x-start linear both,
--_sw-x-end linear both
);
/*
We will be using the “resolution” as a range to
retrieve the width of various things.
*/
--_sw-resolution: 10000px;
animation-range:
0 var(--_sw-resolution),
contain contain var(--_sw-resolution);
animation-timeline: --_sw-x;
/*
Calculating the actual size from the variables applied
via the animation with the given resolution.
*/
--_sw-size:
(var(--_sw-x-start) - var(--_sw-x-end))
*
var(--_sw-resolution)
;
/*
For the main size, clamp the result within limits,
and guard everything with a space toggle.
*/
inline-size: var(--sw-enabled--on,
clamp(
0px,
var(--_sw-size),
var(--_sw-max-size)
)
);
/* Min-size should not be clamped. */
min-inline-size: max(
0px,
var(--_sw-size)
);
/*
We need to always round down the `max-inline-size`;
otherwise, if it uses `cq` units, it could get weird
in Safari.
*/
--_sw-max-size: round(
down,
max(
0px,
var(--_sw-limit)
),
1px
);
/*
Our shrinkwrapped box will behave as `content-box`,
so we need to “enforce” the `content-box` here to
prevent the double-counting of the extra space
that could happen otherwise.
*/
box-sizing: content-box !important;
/*
This can’t be changed from outside for the
base technique.
*/
flex-grow: 0 !important;
/* Allow shrinking when the technique is disabled. */
flex-shrink:
var(--sw-enabled--on, 0)
var(--sw-enabled--off, 1)
!important
;
/* Don’t limit the max size when disabled. */
max-inline-size:
var(--sw-enabled--on, none)
var(--sw-enabled--off, var(--_sw-limit))
!important
;
}
.shrinkwrap-content {
/* Must be specifically `block`, nothing else. */
display: block;
/* Required for establishing the view timeline. */
overflow: hidden;
/*
Required for getting non-anchored positioning
dimensions.
*/
position: relative;
/*
Crucial part: the content element should always
be max-sized and independent of anything else,
unless the technique is disabled.
*/
inline-size: var(--sw-enabled--on, var(--_sw-max-size));
/* Makes shrinking not be hidden by the overflow. */
min-inline-size: min-content;
/*
The following styles should only apply for the
browsers that support `timeline-scope`, which is
currently the furthest from being implemented in
Firefox on 2026-01-26.
*/
@supports (timeline-scope: --f) {
/* Guard by the cyclic toggle. */
inset-inline-start: var(--sw-enabled--on,
min(
0px,
var(--_sw-x-start) * var(--_sw-resolution)
-
var(--_sw-max-size)
)
);
}
}
.shrinkwrap-source {
anchor-name: var(--sw-source, --_sw-source);
/*
Must be specifically `inline` by default.
Allows anchor positioning to measure it’s size
and its offset.
*/
@layer defaults {
display: inline;
}
}
/*
Creating the “probing” element that will be used to
measure the source element’s dimensions and offset.
*/
@supports (timeline-scope: --f) {
/*
We cannot use `::before` here due to a WebKit bug:
https://bugs.webkit.org/show_bug.cgi?id=302703
*/
.shrinkwrap-probe {
position: absolute;
/* Could also be `visibility: hidden` in production. */
pointer-events: none;
/* Anchoring to the source element. */
position-anchor: var(--sw-source, --_sw-source);
/* Not strictly required, but better to be explicit. */
inset-block: 0;
/* Key part for calculating the text-align offset. */
inset-inline: var(--sw-inset, anchor(inside, 0px));
/* Accounting for the inner spacing if needed. */
margin: calc(-1 * var(--sw-inner-padding, 0px));
/* Exposes the element to the scope on an ancestor. */
view-timeline: --_sw-x inline;
}
}
/*
Custom properties that will be applied via scroll-driven
animations, with the value from 0 to 1.
*/
@property --_sw-x-end {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
@property --_sw-x-start {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
/* The keyframes that deliver their values. */
@keyframes --_sw-x-end {
0% { --_sw-x-end: 0 }
100% { --_sw-x-end: 1 }
}
@keyframes --_sw-x-start {
0% { --_sw-x-start: 0 }
100% { --_sw-x-start: 1 }
}
Simple Caseanchor
Let’s look at the case from “The Problem” section again:
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
And now, let’s apply our base technique to it:
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
It works! But how did we achieve this?
Here is the HTMLGo to a sidenote needed for the technique. Instead of just one element, we need to have a pretty specificGo to a sidenote nested structure:
Side note: All the CSS that we had to add for this specific example is --sw-padding: var(--inline-padding) — I will explain this in the base technique’s CSS API later. Jump to this sidenote’s context.
Side note: Obviously, the h5 here is just for this example; otherwise it could be a p, span, or anything else that fits your use case. Jump to this sidenote’s context.
<h5 class="shrinkwrap">
<span class="shrinkwrap-content">
<span class="shrinkwrap-source">
<!-- Text -->
</span>
<span class="shrinkwrap-probe"></span>
</span>
</h5>
shrinkwrapis our topmost wrapper element — one that will receive the final dimensions.shrinkwrap-contentis the inner wrapper that usually will be wider than its parent and will determine how the text content inside will wrap.shrinkwrap-sourceis the additional wrapper around our inline content, which we will target with anchor positioning.shrinkwrap-probeis our anchored element that will measure theshrinkwrap-sourceand which will communicate its dimensions to theshrinkwrapvia scroll-driven animations.
The core idea is relatively simple. To stealGo to a sidenote a Doctor Who metaphor from Amelia Bellamy-Royds:
We want a box that is bigger on the inside than out.
That’s precisely what we’re doing with our technique.
If you read my previous article on this topic, you might wonder, wouldn’t that previous technique that involved just anchor positioning be enough?
The problem with that older technique was that it would only position the anchored element around our inline element — and this would not impact the actual element’s dimensions or position in any way, making the setup pretty awkward.
For example, we would not be able to make the text inside this header be still left-aligned while centering the header itself:
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
The new technique gave the parent shrinkwrap element the correct dimensions, making it possible to continue using CSS as usual, without trying to “fake” its visuals through an external anchored element.
I will omit explaining some styles and will trim various calculations — you can find them in the full code, but in these sections I want to give the general overview of the code involved in the technique.
Styles for the Top Wrapperanchor
Extra options
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
Our top shrinkwrap wrapper has a few important partsGo to a sidenote in its CSS:
.shrinkwrap {
/* 1. Setting up overrideable custom properties. */
@layer defaults {/*…*/}
/* 2. Base styles: some other values are allowed. */
display: block;
overflow: hidden;
/* 3. Scroll-driven animations for remote measuring. */
timeline-scope: /* One timeline */;
animation: /* Two animations */;
animation-range: /* Two ranges */;
animation-timeline: /* One timeline */;
/* 4. Applying the measured dimensions to the element. */
inline-size: /* Cyclic-toggled value. */;
min-inline-size: max(/*…*/);
/* 5. Some important resets. */
box-sizing: content-box !important;
flex-grow: 0 !important;
flex-shrink: /* Cyclic-toggled value */;
max-inline-size: /* Cyclic-toggled value */;
}
-
We use layers and custom properties for defining the CSS API of our technique.
-
Our topmost element should have a normal flow:
blockorinline-blockwill work, as well as it being inside a flex or grid context, but the element itself can’t establish a grid or flex. -
The key part: we use scroll-driven animations to retrieve the dimensions of our inner element. I will expand on this a bit more in a later “Remote Dimensions Measuring” section.
-
Second key part: applyingGo to a sidenote the measured dimensions to our element. The
inline-sizeandmin-inline-sizewill be calculated based on variables that will be applied via scroll-driven animations. -
I also included a few properties that we should never really change for our parent element, as we are sizing it in a very specific way. This part might be adjusted: we could add more properties there or find use cases in which these properties could play a role and require overriding.
Styles for the Content Wrapperanchor
Extra options
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
The shrinkwrap-content is that “inner box” that might be bigger than the actual shrinkwrap elementGo to a sidenote.
.shrinkwrap-content {
/* 1. Base styles. */
display: block;
overflow: hidden;
/* 2. Inner box’ dimensions. */
inline-size: /*…*/;
min-inline-size: min-content;
}
The styles for the inner box are (for now) simple:
-
We need to have some base styles — again, we need to make sure we use a normal flow, and specifically
overflow: hidden, as scroll-driven animations will rely on its presence on this element. -
We have to set the dimensions of our element in a pretty specific way. It will reuse several “private” custom properties that we set on the wrapper and which are based on the custom properties defined by our technique’s API. The
inline-sizehere is the most important part: the way we size it (by default with100cqi) makes it so it is independent of theshrinkwrap’s dimensions.
Styles for the Sourceanchor
The shrinkwrap-source isGo to a sidenote one of the more simple elements:
.shrinkwrap-source {
anchor-name: var(/*…*/);
@layer defaults {
display: inline;
}
}
This element will provide the anchor-name used for our technique, which might be reassigned via the custom properties API.
Then, we need it to be inline by default, as this is what we rely on when measuring our element. However, there are use cases where we are not measuring inline elements, so we apply this style weakly and allow overriding externally.
Styles for the Probeanchor
Finally, we have our shrinkwrap-probe element:
Extra options
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
@supports (timeline-scope: --f) {
.shrinkwrap-probe {
/* 1. Base styles. */
position: absolute;
pointer-events: none;
/* 2. Anchoring to the Source. */
position-anchor: var(/*…*/);
inset-block: 0;
inset-inline: calc(/*…*/);
margin: calc(/*…*/);
/* Establishing the view timeline. */
view-timeline: /*…*/;
}
}
-
This is our absolutely positionedGo to a sidenote element that will measure the source element. We should remove the
pointer-eventsfrom it, as it should not interfere with our page in any way. An alternative could be to usevisibility: hidden, but I find it is much easier to keep it “visible” with justpointer-eventsfor easier debugging. -
We then anchor this element to the
shrinkwrap-source. Later techniques might override some of these, but by default we only care about theinset-inlineand use anchor positioning for defining it. Additionally, we’re using amarginto optionally adjust the measured rectangle. -
Finally, we need to establish an
inlineview timeline, which will mean we could access it on theshrinkwrapelement viatimeline-scopeand itsanimationproperties.
Note that we can also wrap this whole element with an @supports — no reason to do anything with it unless timeline-scope is supported.
Non-Left Text Alignmentanchor
The above styles would be enough for cases when we have just a left-aligned text, as our shrinkwrap-source’s left boundary will be the same, and that would be enough to just shrink the shrinkwrap element.
However, if we were to override the text-align on our element, the following could happen:
Extra options
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
Because our inner box is bigger than the outer, and the text is aligned inside that box, it is not enough to shrink the outer one — we need to also detectGo to a sidenote that offset between the shrinkwrap-content and shrinkwrap-source.
Thankfully, with the way we’re measuring our element, we can reuse the variables that our scroll-driven animation applies and adjust the position of our shrinkwrap-content:
/* Added to the other styles */
.shrinkwrap-content {
position: relative;
@supports (timeline-scope: --f) {
inset-inline-start: min(/*…*/);
}
}
We makeGo to a sidenote our element position: relative, and then use inset-inline-start to adjust its position.
Extra options
I am a Longer Header; I Will Wrap & Look Bad! Oh no! What Can We Do?
I imagine there could be other text edge cases that the base technique does not cover — if you’ll think of anything, please let me know!
Multiple Nested Phrasing Contentsanchor
While the base technique is limited to only shrinkwrapping elements with phrasing content, more complicated cases could be covered by reusing the technique multiple times for every instance of such content inside.
For example, we could have a list with several items, and then we’d want to put it into a card with its edges flush with the content of all items inside. For this, we can make our list the container and then wrap the contents of each list item with the technique and then size the card with max-content — and it will just work! And if some list items have multiple paragraphs, we use the technique once per paragraph.
This example shows how we can have a list take up to 50% of content while shrinking to fit the wrapped items that have text-wrap: balance, which usually results in a worse visual experience.
-
Some non-wrapping item.
-
Another item, this time with more content, wrapping onto a few more lines.
In short, we can use our base technique as a building block for anything that is more complex. The later sections expand on this base technique but adjust how we measure things and what exactly we are measuring.
Base Technique’s APIanchor
The HTML of the technique is a part of its API:
<div class="shrinkwrap">
<div class="shrinkwrap-content">
<span class="shrinkwrap-source">
<!-- Text -->
</span>
<span class="shrinkwrap-probe"></span>
</div>
</div>
-
The
shrinkwrapand theshrinkwrap-contentcan be anything apart from thediv, but they can only have thedisplay: block(which is applied by the technique but should not be overridden). -
The
shrinkwrap-sourcedefines what we will be measuring, and by default hasdisplay: inline. It is possible to apply it to some other element or skip this element completely if we’re overriding what we’re targeting with theshrinkwrap-probe. -
The
shrinkwrap-probeis our measuring element, it must be strictly inside theshrinkwrap-content, and by default is measuring theshrinkwrap-source. We can override what theshrinkwrap-probeis measuring by overriding itsinset.
Alongside HTML, we can define a set of CSS custom properties on the shrinkwrap element:
-
--sw-limit— the key custom property that has the default of100cqi. We can use it when we want to place some other elements alongside ours on the same “row”. The above “Multiple Nested Phrasing Contents” use one such case, where we set it to, essentially,50cqi - 3 * var(--gap) - var(--list-item-padding)— defining the maxGo to a sidenote limit that the text inside could take to be a half of the container, minus all the paddings and gaps that the surrounding layout has. -
--sw-paddingcan be used when we have a uniform padding around the element that we’re sizing. We are in a something similar to thebox-sizing: content-boxcontext when we’re using this technique, so we can use this custom property to communicate the possible adjustment. It is similar to using acalc()inside a--sw-limit, and often is a more simple way of handling the paddings, but more complex cases might be better solved with the calculated--sw-limit. -
--sw-inner-paddingis a bit different, and might be mostly used for more complex cases that involve--sw-insetor--sw-sourceoverrides. This is a custom property to account for any padding that could be present between theshrinkwrapand the measured content inside theshrinkwrap-content. -
--sw-insetcan be used to override the value of theshrinkwrap-probe’sinset, making it possible to anchor it to multiple elements for more complex cases like the “Multiple Explicit Anchors”. This is where the--sw-inner-paddingcan also be useful, as it will be automatically used for calculations that can be trickier to achieve with theinsetshorthand. **Note: ** this custom property is used specifically forinset-inlineproperty, and notinset. -
--sw-sourceis for those rare cases where you’d want to override theanchor-nameof theshrinkwrap-probeelement. This can be useful for any complex techniques where the measured element lives outside theshrinkwrapelement, allowing us to “link” them. See my “Inline Custom Identifiers” blog post that covers this way of connecting elements.
Remote Dimension Measuringanchor
I am using a technique, the basics of which I came up with somewhere in 2023, after publishing my second article about scroll-driven animations. I did not write about this technique anywhere yet, but had a few drafts with various use cases for it that I sometimes worked on in background.
Thankfully, someone else came up with a similar technique and wrote about it — it was Temani Afif and his “How to Get the Width/Height of Any Element in Only CSS” article from July 2024.
There is one difference between our techniques: Temani relies on the “measuring” element when it is at the beginning of the box, and then using its 1px dimension and its proportions relative to the scrollport to calculate that scrollport’s dimensions.
Because I am using anchor positioning to place my probing element at a very specific point — which I want to measure — I, instead, rely on a very high value of the timeline-range, which I store in the --resolution custom property.
When some view timeline then reports its position in this range, by knowing this “resolution” we can then retrieve that position through scoping the timeline to another element.
Here is the CSSGo to a sidenote responsible for the scroll-driven animations that I previously skimmed through:
.shrinkwrap {
/* Lifting the scope of view timelines from inside. */
timeline-scope: --_sw-x;
/*
Accessing the start and end coordinates of the
inner element via scroll-driven animations.
Only apply when the technique is enabled.
*/
animation: var(--sw-enabled--on,
--_sw-x-start linear both,
--_sw-x-end linear both
);
/*
We will be using the “resolution” as a range to
retrieve the width of various things.
*/
--_sw-resolution: 10000px;
animation-range:
0 var(--_sw-resolution),
contain contain var(--_sw-resolution);
animation-timeline: --_sw-x;
/*
Calculating the actual size from the variables applied
via the animation with the given resolution.
*/
--_sw-size:
(var(--_sw-x-start) - var(--_sw-x-end))
*
var(--_sw-resolution)
;
/*
For the main size, clamp the result within limits,
and guard everything with a space toggle.
*/
inline-size: var(--sw-enabled--on,
clamp(
0px,
var(--_sw-size),
var(--_sw-max-size)
)
);
/* Min-size should not be clamped. */
min-inline-size: max(
0px,
var(--_sw-size)
);
}
.shrinkwrap-content {
@supports (timeline-scope: --f) {
/* Guard by the cyclic toggle. */
inset-inline-start: var(--sw-enabled--on,
min(
0px,
var(--_sw-x-start) * var(--_sw-resolution)
-
var(--_sw-max-size)
)
);
}
}
@supports (timeline-scope: --f) {
.shrinkwrap-probe {
/* Exposes the element to the scope on an ancestor. */
view-timeline: --_sw-x inline;
}
}
/*
Custom properties that will be applied via scroll-driven
animations, with the value from 0 to 1.
*/
@property --_sw-x-end {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
@property --_sw-x-start {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
/* The keyframes that deliver their values. */
@keyframes --_sw-x-end {
0% { --_sw-x-end: 0 }
100% { --_sw-x-end: 1 }
}
@keyframes --_sw-x-start {
0% { --_sw-x-start: 0 }
100% { --_sw-x-start: 1 }
}
I am planning to write a separate article that will explain how this dimension measuring works in detail, and I would rather not repeat myself in this one (and make it even longer).
So — stay tuned for that next article!
A Crashing Safari Buganchor
As mentioned in the disclaimer, this technique is very experimental, and the CSS features it relies on are pretty new, and can sometimes cause issues even though they’re there in the “stable” versions of major browsers.
Initially, I was creating the probing elements as pseudo-elements, but while testing my article, I found that in certain conditions my article was crashing its tab in Safari.
After reducing the code to its minimal reproductionGo to a sidenote, I found that the probing element being a pseudo-element was one of the conditions for the crash to happen, so I adjusted the technique by replacing it with a real element.
Initially, my technique also had another pseudo-element that did not trigger the issue, but I managed to simplify my technique to allow doing both measurements from the single additional element.
Solving for Complex Contentanchor
The base technique works for the simple case where our source element has phrasing content: only inline (and inline-…) elements inside.
But what about more complex cases, like when we have multiple items inside wrapping flexbox, grids, etc?
The simpler cases which we could separate into several independent phrasing contexts can be solved by repeating the base technique — see “Multiple Nested Phrasing Contents”, but not everything can be done this way.
Multiple Explicit Anchorsanchor
I covered this use case and a few of my approaches to a solution in my previous article, in a “Wrapping Flex Items” section, but the new technique improves on those.
In short, if we have a wrapping list of items, either inside a grid or a flex container, if we know the number of items, then we can assign a unique anchor to every item and then use a min() function involving all the anchors to find the “furthest” element that could be used to determine our shrinkwrapped dimension.
- An item
- Second item
- Third item
- Fourth item
- Fifth item
- Sixth item
- Sixth item
Because I abstracted the measuring into a separate element, implementing this is as easy as doing the followingGo to a sidenote (after stripping visual styles unrelated to the technique):
li {
padding: var(--padding);
anchor-name: var(--is);
}
.shrinkwrap {
--sw-inner-padding: var(--padding);
--sw-inset:
min(
anchor(--a inside, calc(infinity * 1px)),
anchor(--b inside, calc(infinity * 1px)),
anchor(--c inside, calc(infinity * 1px)),
anchor(--d inside, calc(infinity * 1px)),
anchor(--e inside, calc(infinity * 1px)),
anchor(--f inside, calc(infinity * 1px)),
anchor(--g inside, calc(infinity * 1px))
)
;
}
And the HTML is pretty simple: we wrap our list with our shrinkwrap technique:
<div class="shrinkwrap">
<div class="shrinkwrap-content">
<ul>
<li style="--is: --a">
An item
</li>
<!-- the rest of the items -->
</ul>
<span class="shrinkwrap-probe"></span>
</div>
</div>
Because what we’re measuring is not inline, we are not using the shrinkwrap-source class that would assign the target that we measure; that’s where the --sw-inset custom property comes into play: we can use it to reassign how the shrinkwrap-probe will be positioned by overriding its inset property, and thus what exactly it will measure.
The only thing that we need to do here is use the min() function and pass all items’ anchors inside, making it possible to compare the inset positions of all the items and choose those that make the biggest bounding box.
In addition to this, we can use the --sw-inline-padding to accommodate the padding around items, which, in this case, is much easier to do than adding a calculation to the min().
Chained Anchors Abominationanchor
Of course, the above code relies on knowing the number of elements, assigning the unique anchor identifiers to them, and then listing all of them inside a min(). But what if I tell you that we could achieve this without doing so?
Well, we can, but, for now, this works only in Chrome — this relies on the ability to chain multiple anchors together, and that currently only works reliably in Chrome, while Safari and Firefox have pretty serious bugsGo to a sidenote with that behavior.
Extra options
- An item
- Second item
- Third item
- Fourth item
This time, HTML is a bit more simple in one way (no unique idents), but more complex in another (additional probe elements, two per item):
<div class="shrinkwrap">
<div class="shrinkwrap-content">
<ol>
<li>
An item
<div class="probe-left"></div>
<div class="probe-right"></div>
</li>
<!-- the rest of the elements -->
</ol>
<div class="shrinkwrap-probe"></div>
</div>
</div>
</div>
You can see how, alongside the shrinkwrap-probe element, each element has its two probe-left and probe-right elements.
But how, without a min(), can we use this to measure the bounding box for all elements without explicitly mentioning them?
Here is the CSSGo to a sidenote responsible for this:
li {
anchor-name: --li;
anchor-scope: --li;
}
.probe-left,
.probe-right {
position: absolute;
pointer-events: none;
inset: anchor(--li inside);
container-type: inline-size;
&::before {
content: "";
position: absolute;
inset: 0;
}
}
.probe-left {
left: anchor(--li left, 0);
right: anchor(--leftmost left, anchor(--li right));
@container (min-width: 1px) {
&::before {
anchor-name: --leftmost;
}
}
}
.probe-right {
left: anchor(--rightmost right, anchor(--li left));
right: anchor(--li right, 0);
@container (min-width: 1px) {
&::before {
anchor-name: --rightmost;
}
}
}
.shrinkwrap {
--sw-inner-padding: 1em
--sw-inset:
anchor(--leftmost left)
anchor(--rightmost right)
;
}
Starting from the end — as we don’t have a shrinkwrap-source, we override --sw-inset to get the insets from the leftmost and rightmost items, which will be determined later.
Then, we can assign a scoped anchor name to our items:
li {
anchor-name: --li;
anchor-scope: --li;
}
We need to scope it so the probe elements inside the items would see the correct anchors; otherwise, they could look up at the last one they see.
What we do next is pretty fun: we use this parent <li> as the anchor for the nested probes via inset: anchor(--li inside) on them and also make these probes inline containers via container-type: inline-size, which makes it possible to query them on inner pseudo-elements.
Then, we do this for the probe that measures the right edge of our boundary box (and mirror it for the probe-left):
.probe-right {
left: anchor(--rightmost right, anchor(--li left));
right: anchor(--li right, 0);
@container (min-width: 1px) {
&::before {
anchor-name: --rightmost;
}
}
}
We rely on an ability to “chain” the anchorsGo to a sidenote: anchor to the previous valid anchor target among multiple that share the name. Then we can use container queries to check if our probe has a positive width — that would mean that its edge is to the right from the previous rightmost probe.
In a way, this is an algorithm for determining the maximum value of something expressed via pure layout — anchor positioning and container queries! It relies on the order the elements are laid out: the later elements can anchor to earlier elements, so they can use the dynamic anchor names that are applied inside container queries.
If it is difficult to understand what is going on, highlighting those probe-left and probe-right elements could help:
Extra options
- An item
- Second item
- Third item
- Fourth item
Going with elements one by one, we position both probes on both sides, each going from the edge of the last pseudo-element that was placed into a positive container query or falling back to the first element’s dimensions. This makes it so, going through all elements, we will create “steps” out of our probes, where only the rightmost and leftmost ones will have positive dimensions.
Too bad Safari and Firefox don’t work well with chaining just yet…
The final and the hardest use case I want to cover is something like a navigation menu, in which all elements should participate in a single flex context, and with these elements shrinking if there is not enough space for them.
If we take some horizontal menu with just a few shorter items, then everything looks fine when all items fit in:
But here is what happens when the items are longer, there are more of them, and not enough space to have them all without wrapping:
At the core, this is the same issue as with the other shrinkwrap problems: once wrapped, the element tries to take as much space as it can, and when there are multiple elements fighting for that space, it will be re-distributed.
It would’ve been great if the base technique I presented above could work for that case. But it doesn’t: for the base technique to work, we have to know the limits in which we’re working, but when our element depends on all other elements and shrinks proportionally based on all the extra elements present around, we cannotGo to a sidenote achieve it with what we have for now.
The only way to solve it that I found is with content duplication: first, we can render our menu as regular but hidden, measure the wrapped elements, and then transfer their dimensions to the visible copy, one by one.
The whole setup can then be placed inside another instance of our base technique, so we could collapse the whole menu and allow elements around it to stretch over the space that we gain.
Here is what it can look like when adding a few more elements to the example:
While I managed to make it work in this case, I found that there are more problems with these kinds of menus. If you resize it, on narrower screens, things become pretty bad. Some container queries and switching up the layout could work, and there are things that could be improved with this solution further.
I won’t provide a more detailed explanation of how this works and won’t show any code, at least for now: it is very fragile, and maybe once I play with this type of layout more, I could see a better way to apply my technique. Or come up with something else.
But what this demonstrates is that the complex cases are complex, and while it is possible to come closer to solving them, there are still many unknowns over how exactly they should be solved.
Some Other Use Casesanchor
Before I end the article, I don’t want it to stop on the sourer note with the previous not fully solved case. So, let’s return to some examples from my last article on shrinkwrap, and demonstrate how the new technique solves them much better. Plus, I’ll add another two use cases that I did not cover in my previous article.
Chat Bubblesanchor
One of my favorite examples is the chat bubbles — you can see this everywhere, from phone apps to video games. I imagine native frameworks have their ways of doing this, but on the web we could not achieve this look with just CSS until now.
Hello, there!
Oh hey, this is a bubble with a lot of text, so it would wrap, hopefully!
This is cool! I like how the bubbles go neatly around the wrapped text.
The styling is now simpler than what we have with the previous technique. For example, we can use regular absolute positioning, like for the emoji on the previous message!
We can even have multiple paragraphs inside, just each will need to be wrapped with our technique!
The implementation of this is so much simpler than what I had to do when trying to fake it by anchor-positioning the bubble’s background separately! Now we just give max-width: max-content to the blockquotes inside and then wrap each paragraph with our technique, like that:
<blockquote>
<p class="shrinkwrap">
<span class="shrinkwrap-content">
<span class="shrinkwrap-source">
Hello, there!
</span>
<span class="shrinkwrap-probe"></span>
</span>
</p>
</blockquote>
And then the only extra CSS that we have to add is the definition of the --sw-limit and --sw-padding:
.example-bubbles .shrinkwrap {
--sw-padding: var(--padding-inline);
--sw-limit: calc(
100cqi
-
(
var(--margin-start)
+
var(--margin-end)
)
);
}
We have to account for all the paddings and margins we can have and add inline-size containment on our example wrapper, but otherwise this shows how much easier it is to use.
Fieldsets and Legendsanchor
In the corresponding example in my previous article on this topic, I relied on the legend expanding and then re-adding the borders, faking them via added pseudo-elements.
The below legend is using the new technique and doesn’t have any faked . It fully relies on the “magic” behavior of the native fieldset and legend:
And, again, this example shows how text-wrap: balance could improve things, but only if we could shrinkwrap it natively.
Overlay Image Captionsanchor
This use case wasn’t in my first post about shrinkwrap but was provided by Johannes Odland to me in a private conversation, where he mentioned that they had cases like this at NRK.
Let’s say we want to have a figure with an image and want to put the caption on top of it with a semi-opaque background that is flush to the caption’s text so we could minimize the area it covers. As with other cases, when the text is short, everything is ok, but when it wraps, it will span all the available space. Shrinkwrapping will help with this a lot!
Tooltipsanchor
The last use case I’ll show in this article is one that we have in Datadog, and that is likely familiar to anyone dealing with design systems — tooltips and popovers. Pretty often you want the content in them to be nice and balanced, but have a certain limit, usually much smaller than the width of the viewport. Without shrinkwrapping, this can lead to rather ugly results. But our technique allows creating pretty and neat tooltips.
I wish the HTML for the technique was as pretty as the result — while all the demos above show how it works, it is hardly easily applicable for user-generated content, and even when you have full control over your HTML, it can be pretty cumbersome.
What Next?anchor
To me, it is clear that the most basic use cases — when we know the max-inline-size our element can take — should be achievable in browsers with either a new property or a new function that could be used for size properties. It might require containment or something similar, as there is still a chance the percentage-based dimensions could lead to some circularities. But even with the required containment, what this will allow us to achieve will cover so many things people wanted to do for more than a decade now.
I don’t think we need to pursue solving the menu case (cross-dependent shrinkwrapping) for now — it is a much, much more complicated layout. But I believe if we work out the simple cases first, we could crunch on those low-hanging fruits and see if we could make some more complex jams out of them later.
I will post a link to this article and my proposal to explore the simpler solution todayGo to a sidenote in the corresponding CSSWG issue, and if you had stumbled upon this problem before and have any specific use cases, bring them up and maybe even see if my technique will cover them.
And I will also be working on another article — one that will cover my method of remote dimension measuring technique, so stay tuned for that and more! Although, likely, it won’t be anywhere soon, as these articles take a long time to research and write.
Published on with tags: #Anchor Positioning #Scroll Driven Animations #Future CSS #Experiment #CSS