Debug panel

Close debug panel
Roma’s Unpolished Posts

Responsive Cyclic Margins

Published on:
Last updated on:
Categories:
Obscure CSS 5, CSS 88
Current music:
Mitski
Pearl Diver
Current drink:
Infusion of hibiscus, apples, rosehip, and orange peels, with cherry flavor.

The title of this post might not seem like a big deal, but the problem I will talk about was pretty fun to work out: how do you make a margin between elements responsive based on the available space in their parent, and such that it does not collapse in a min-content context?

The Problem

That might seem to be pretty specific, but let’s say we have an element with two parts like a button, with the text disappearing when there is not enough space:

<p>
	<button type="button">
		<span class="ellipsis">Hello, world!</span>
		<span class="icon">🌐</span>
	</button>
</p>
<p style="width: var(--min-width)">
	<button type="button">
		<span class="ellipsis">Hello, world!</span>
		<span class="icon">🌐</span>
	</button>
</p>

What we want is to add a margin between the text and icon here, but make it disappear when the button collapses.

Any static margin added to either span there will be still there when we collapse the button to be narrower than the available space:

.ellipsis.with-margin {
	margin-right: 0.5em;
}
.icon.with-margin {
	margin-left: 0.5em;
}
Expanding the resizable blocks will show the margin between the text and its icon.

And, because our buttons has to shrink to fit its contents, we can’t use inline-size containment and size the margin via container query length units. So, what to do?

An Attempt

You could think: hey, we can use % in CSS, which will be evaluated based on the width of the parent element… Could we use it in some way, like with a calculation?

.icon.with-naive-percentage {
	margin-left: clamp(
		0px,
		100% - var(--min-width),
		0.5em
	);
}

It seems to wor— oh wait, why is there an ellipsis? And even when we expand our resizable paragraph, it stays this way?

What we attempted to do is to make it so we will have a margin that depends on the parent’s width, and goes from 0.5em to 0px. By subtracting the --min-width variable we can make it go to zero when approaching that min-width.

And it kinda works: when it is narrow, there is no margin, when it is wider — margin appears, but something breaks.

Percentage Evaluation

The answer to what is happening — why we have the text truncated even though it seems like we should have space available, lies in the Intrinsic Contributions of Percentage-Sized Boxes section of the CSS Box Sizing Module Level 3 spec, specifically the paragraph “d.:

[…] for margins and paddings (and gutters), a cyclic percentage is resolved against zero for determining intrinsic size contributions.

Because the button shrunks to its max-content width when it is inline-…, this width is calculated by adding up all the childrens’s contibutions, and for our margin we substitute any % inside with 0. So, for calculating this width, the following:

margin-left: clamp(
	0px,
	100% - var(--min-width),
	0.5em
);

Can be treated as

margin-left: clamp(
	0px,
	0px - var(--min-width),
	0.5em
);

And so it will become just 0px, so the total width of the whole button will consist of its borders, paddings, text content, and the icon. Plus zero for our margin.

Then, knowing this width, we start to layout everything inside, and according to our formula the margin becomes 0.5em as the width is quite large… but that reduces the space available for our text, so it becomes truncated!

The Solution?

To solve this, we can introduce even more percentages but in reverse!

.icon.with-percentage {
	--margin: 0.5em;
	margin-left: clamp(
		max(0px, var(--margin) - 100%)
		,
		100% - var(--min-width)
		,
		var(--margin)
	);
}
No unnecessary truncation in sight! Unless you look at this in Safari.

Now it works! When wide — without collapsing — the margin is 0.5em, when narrow — it is zero.

What we did is replaced the lower boundary of our clamp() with a max() that, when considered in a cyclic percentage context results in 0.5em, and otherwise results in 0px. Whoa!

…Except for the Safari Bug

Ooops. That’s what happens when you test out an obscure place in CSS, and check how it behaves in different browsers. Instead of only evaluating the % as zero, Safari just throws the whole calculation away and replaces the whole margin with zero T_T

<p>
	<button type="button">
		<span>button</span>
	</button>
</p>
button > span {
	margin: calc(0% + 50px);
}

Safari renders this example not as you would expect… Of course, I opened a bug about this.

The Working Solution

Right after I published this post, I suddenly decided to test what will happen if, instead of a margin, we would use a gap? **It works! **

.with-gap {
	--gap: 0.5em;
	gap: clamp(
		max(0px, var(--gap) - 100%)
		,
		100% - var(--min-width)
		,
		var(--gap)
	);
}
No unnecessary truncation in sight! Even in Safari!

So, this is the solution I will recommend: instead of a margin, use a gap, with the rest of our calculations remaining in place: without the negative cyclic percentage condition the button would still collapse, but now the gap works the same in all browsers — likely because it was implemented later than margin. Ooof.

An Alternative Flex Solution

While I like the solution with the gap, and when Safari will fix the bug with margins/paddings, this technique could be potentially used in non-flex contexts (what about floats?), the next morning after publishing this article, I decided to think of possible alternative solutions.

Thinking about gap and flexbox, I found a solution that was likely one people use to achieve this effect right now: having a shrinking spacer element.

<p>
	<button type="button">
		<span class="ellipsis">Hello, world!</span>
		<span class="spacer"></span>
		<span class="icon">🌐</span>
	</button>
</p>
<p style="width: var(--min-width)">
	<button type="button">
		<span class="ellipsis">Hello, world!</span>
		<span class="spacer"></span>
		<span class="icon">🌐</span>
	</button>
</p>
.spacer {
	width: 0.5em;
}
Works well everywhere.

Well, that’s pretty simple! The main con of this solution is that it requires one additional element per gap that you want to make responsive, but there are a few pros as well:

  • Unlike gap and similar to margin, it is possible to style each gap separately.
  • In previous solution, we had to know the min-width to describe the gap, here we rely on the default flex-shrink behavior.
  • The shrinking itself is much smoother, and we can control the ratio of how the gap shrinks compared to the other elements by using different values of flex-shrink.

Conclusion

Not always you stumble over a cyclic percentage issues, and not always you can overcome them by exploiting the same substitution to zero with conditional calculations.

I feel this is just a glimpse of what these kinds of calculations could achieve, and I invite you to also try and hack any similar places in the same way. Maybe you’ll stumble over something fun too!

As usual, it is also fun how we can find different solutions for the same problem in CSS, each with its own pros and cons. Similar to what I wrote in Minimal Reproductions, knowing what exactly does not work allows you to pursue alternative solutions and find one that works.

Please share your thoughts about this on Mastodon!