[css-fonts-4] Feature for making text always fit the width of its parent
tobireif opened this issue Β· 34 comments
This thread shows that it's a widely required feature:
https://twitter.com/sindresorhus/status/979363460411609091
Example of a workaround: Open https://tobireif.com/ and (eg using dev tools responsive mode) resize the page down to 250px width while watching the text "Welcome".
This requires performing layout in a loop, which we generally have avoided. Requiring a round-trip through JS is valuable because authors are more likely to realize it has a large perf cost
In the JS at https://tobireif.com/ I perform two passes - that's plenty for a good-enough result, and it doesn't impact perf in any noticeable way (the text-fitting is only done once in addition to the first main run). That could be a great option for browser implementers as well, and it shows that supporting such a CSS feature is very feasible.
If this were a feature, I think it'd be best if it was a CSS function. (similar to calc
or minmax
)
Something like font-size: fit(8px, 48px);
where 8px
is the minimum font-size and 48px
is the maximum font-size.
I think using a function, other than being useful for minimum and maximum sizes, relays the gravity of using the feature since surely it'll have some performance issues in extreme cases.
I'd love to see this in CSS!
Great suggestions!
A lower limit and an upper limit both make sense.
Instead of font-size: fit(8px, 48px)
it might be better to name it eg font-size: fit-width(8px, 48px)
.
Changing font-stretch, especially using variable fonts, is another way to fit text into parent. Or compression during justification . So if there is a css property that instructs layout engine to fit, it should allow different methods and so likely be separate from font-size.
And this kind of functionality may not only be on line operation. It may be useful for more advanced functionality, like optimal paragraph layout, line clamping, or simple ellipsis.
Changing font-stretch, especially using variable fonts, is another way to fit text into parent. Or compression during justification . So if there is a css property that instructs layout engine to fit, it should allow different methods and so likely be separate from font-size.
True! (also eg letter-spacing)
And this kind of functionality may not only be on line operation. It may be useful for more advanced functionality, like optimal paragraph layout, line clamping, or simple ellipsis.
Let's start simple π If we're asking for too much we might not get anything. The basic simple use case of fitting one line of text (eg a heading) into its responsive parent is so common that a solution for that would cover a lot (and more could get added/specd later).
Yes it's feasible to implement the functionality using JS, and yes there are workarounds, and I think there even is a lib, but it sure would be very handy to be able to simply write one single line of CSS instead.
My implementation in the source of https://tobireif.com/ is more than 50 lines of JS - if people could instead write a single line of CSS then that would save a lot of typing.
By the way @litherum : If the implementation is smart enough, perhaps one pass would be sufficient β no loop / double-pass.
Perhaps the syntax could look like this:
fit-width: font-size(20px, 100px);
fit-width: letter-spacing(-0.1em, 1.5em);
fit-width: any-text-width-affecting-property(min, max);
The sizing/fitting should honour the (potential) padding of the container.
Using a small number of passes is unlikely to work in the general case, because if we get it wrong, the text will overflow its container and wrap, which would be catastrophic. Any generalized implementation would have to iterate until the algorithm converges. Such an algorithm would be a great way to make a browser hang.
If you do want to provide this widely requested feature - perhaps you could try it out π If your algorithm is smart regarding calculating the estimated target value, it will not need many passes, and it might need only one or two passes. For all and any cases.
Such an algorithm would be a great way to make a browser hang.
When you try it out, and limit the maximum number of passes to 2 == no browser hang at all, and if your algo can estimate the correct value pretty well, then there's a good chance that the result will be sufficiently good. You'd have to try it out though.
If you do not want to provide that feature no matter what, and thus do not want to create a quick "beta" implementation for seeing what's feasible, then there's not much reason to continue the discussion. In that case please close the ticket.
I did create a quick implementation using JS and found that it works sufficiently well using only two passes. The code is at https://tobireif.com/ -> source -> "var topLevelHeadings". It's just a quick (but good enough for that case) implementation - I'm 100% sure that you could come up with a much better (and generally applicable) algo π
Here's another JS implementation:
http://fittextjs.com/
https://github.com/davatron5000/FitText.js
None of the above implementations causes any noticeable performance issue. And: The latter is a general lib.
As for your own site, the type inside your headers is simple enough that you'd have performance gains in just using vw inside a breakpoint, reducing 50 lines of runtime js to possible 4 lines of css.
I'd prefer CSS that's based on the parent element width, not on the viewport width. (Because generally the element width might change without the viewport changing.)
The feature is a (very popular) wish - the specification of that feature (including all relevant details) would be up to the CSS WG.
(Oh, and if that feature would be only feasible to spec/implement for a defined set of simple types of cases, I'm sure that simple feature would be widely appreciated as well π The syntax still could be fit-width: any-text-width-affecting-property(min-value, max-value)
, I think.)
If and when there will be an ew unit ("element width", as in EQCSS), and if and when there will be clamp() , then the functionality in this feature wish ticket here could be expressed sufficiently succinct, eg:
font-size: clamp(30ew, 20px, 80px);
Where are you getting 30
in the 30ew
? Are you matching that to the length of the string in the element? Or is 1ew
just 1% of the elements width, meaning a 100px
wide element would set its font-size to 30px
?
Here's the definition of ew
, eh
, emin
, and emax
from jsincss-element-units:
switch(unit) {
case 'ew':
return tag.offsetWidth / 100 * number + 'px'
case 'eh':
return tag.offsetHeight / 100 * number + 'px'
case 'emin':
return Math.min(tag.offsetWidth, tag.offsetHeight) / 100 * number + 'px'
case 'emax':
return Math.max(tag.offsetWidth, tag.offsetHeight) / 100 * number + 'px'
}
I was thinking of isolating just these tests into their own package (and maybe the element query tests from jsincss-element-query) so other plugin builders could more easily re-use the same tests in their plugins.
Yeah, it'd not be the real deal where the implementation figures out the value required for making the text fit its container. It'd just be a pragmatic way to get the feature with just one line of CSS.
(And yes, 30ew means 30% of the element width. The exact number is just an example, it could be eg 45.5ew .)
(I was replying to @jonjohnjohnson , just so there's no misunderstanding @tomhodgins π)
Ideally we could state in CSS "always fit this word/line of text inside its parent (by auto-adjusting the property "foo" eg font-size or letter-spacing), no matter what font is used".
We now do have clamp()
to specify lower and upper bounds. Alas, we can only reference character width (ch
, ic
) or height (em
, cap
) and viewport dimensions (vw
, vh
etc.), not line or box width, as units. (Units for container dimensions have been proposed in #5888.) So you could only approximate the result for an assumed number of characters per line.
For the desired capability we would need new keywords or functions indeed.
For the desired capability we would need new keywords or functions indeed.
Yep π
No one has mentioned the SVG textLength
property, which already does this. The functionality is also part of AH formatter: https://www.antenna.co.jp/AHF/help/en/ahf-ext.html#axf.overflow-condense
Their algorithm applies to a block, not a line - I expect that the text is progressively adjusted and layout retried until it fit. It's certainly going to be multi-pass and expensive - you can take a guess at a start value easily enough, but word-breaks at the end of the line necessarily make the algorithm iterative to find the best value. Doing it once for print layout is one thing, but it would be horrendous if you were resizing a window with this on.
We've been asked for similar functionality a few times over the years, but I believe only ever for "fit to line" rather than "fit to block". I think it's more of an issue in print, at least until they start selling paper with a horizontal scrollbar.
If you restricted it to just scaling either font size
or font-stretch
, and you restrict it to just scaling text to fit a single line, then it's theoretically a single pass - it's just a multiplier applied to the property. But it gets rapidly more complex when you've got only part of the line scaling this way, or you have multiple items on the line doing this with different layout properties - for instance, imagine a float and two spans with different initial font-sizes on the line, all trying to scale themselves to fill the line. It's all stuff that would need defining.
@faceless2 wrote:
If you restricted it to just scaling either
font size
orfont-stretch
, and you restrict it to just
scaling text to fit a single line [inside a box]
That would be sensible (with font size
as default).
Iβve seen cases where this has been applied to each word, for some definition of word. Nonetheless, I guess it would be fine to do this by fitting the whole textual box content on a single line.
(An l
or line
element would have been better than br
in HTML.)
Hi all, I'd like to revive the conversation and provide another perspective on the utility of supporting a feature like this in CSS.
There are many designs that leverage careful placement and styling of text. A lot of time is spent by designers and engineers to implement these designs, but often only just in English. As soon as the text gets translated to another language, especially if the translation is much longer or shorter, applying the same CSS to the text that worked for English often causes issues such as text overflowing, truncating, breaking mid-word, widows, etc. As a result, this feature would make it easier to localize text while preserving design intent.
This requires performing layout in a loop, which we generally have avoided. Requiring a round-trip through JS is valuable because authors are more likely to realize it has a large perf cost
There are several JS libraries that attempt to implement this resizing. However, one limitation of a JS implementation is that it causes layout shifts for server-side rendered (SSR) pages. Since the server does not reliably know the dimensions of the client's device, the text needs to wait for the page to be hydrated before resizing. If supported in CSS, text would be able to render at just the right size even on initial render of SSR'ed pages.
In addition, while performance is certainly a consideration, other expensive CSS features such as animating height also exist and the performance implications are well known. Given the benefits of a "FitText" feature, it would be nice to be able to support this and allow developers weigh the performance cost against the benefits for their use case.
I want us to return to this issue β we now have inline-size
containment, which could be used to solve the potential issues regarding the circularity.
The list of things a potential solution for βfit to widthβ text should handle the following, in my opinion:
- It should work only inside an element with
size
orinline-size
containment. - We need to have an ability to set a min/max font-size.
- We could use the existing font-size as the minimum β this would guarantee a) readable font-size when too much content/too narrow context, b) better graceful degradation.
- I'd argue that introducing a new property instead of using something like
fit-width(8px, 48px)
value for thefont-size
could be more preferable: easier to detect the intent, easier to fall back to the regular font-size when you'd forget the containment/when the feature is not supported. - I think it is better to have a single property that would trigger the fit-to-width and set the maximum. Better to encourage having some limit, and someone who don't want to be limited could still set it to an arbitrary big number as a work-around.
- Should work with multi-line text:
- If multiple lines of text are present (with hard-breaks, like with
<br/>
or inpre
context) the longest line should be used for this limit. - By default should fit as many text as can fit until meeting the
min
font-size, then wrap. - Maybe there could be an option to force the βfit-to-widthβ on all the wrapped lines until they fit again β but only as an option, as both behaviors have use-cases.
- Optionally, not sure if easy to implement β should work nicely with
text-wrap: balance
. Logically, with the simplest form of balancing, it sounds not super complicated: calculate the initial wrapping opportunity based on the min font-size, then balance things using thetext-wrap: balance
, then bump the size either based of the longest line for everything, or for each line separately, based on the preference from the previous item.
- If multiple lines of text are present (with hard-breaks, like with
For those who could want to experiment with CSS-only way of achieving this, I just wrote an article about how we can use scroll-driven animations (at the moment available in Chrome Canary) to do just that β https://kizu.dev/fit-to-width-text/
Compared to what is proposed in this article, the main limitation of that method is the absence of the min value for the font-size, alongside overall hackiness of the method, so I would still want to see this implemented in CSS natively :)
Would it be useful to introduce this as a function which could optionally accept a percentage arg signifying how much of the available space the text should span?
For any bounds need, we could just use the built-in clamp function along with a new βfitβ keyword, like font-size: clamp(8px, fit(), 48px)
where fit()
would have a default arg value of 100%.
I was reading this thread from the top and was thinking exactly that, that some kind of fit-to-width option inside clamp would be awesome.
Alternatively if we could specify the size of a font by its average width (like by the ch unit?), then it might get us a step closer to this functionality, even if not perfectly so.
I want us to return to this issue β we now have
inline-size
containment, which could be used to solve the potential issues regarding the circularity.
I started a Codepen example to help solve this: https://codepen.io/nathanchase/pen/rNKqYoX
Could we somehow utilize ch
or ic
units inside a calc()
, and then clamp the font-size based on the container inline-size?
Hey, everyone. I just published an article about my new technique for achieving a fit-to-width text:
https://kizu.dev/fit-to-width/
Unlike my previous article, it does not use scroll-driven animations, and is purely based on container query length units and some calculations involving registered custom properties, so it now works in all recent versions of modern browsers.
The gist of how it works: by duplicating the text, we can measure the smallest version of the text (by measuring the space that remains if we subtract it from its container), and then use the ratio between it and its container to adjust the size to 100%.
While there are some potential limitations for this technique if the font will specify alternative display for the glyphs based on the size, the pros of this method should be enough to see if we could adapt it as a native CSS property.
The exact name and syntax are to be specified, but the gist of my proposal is following:
- The new property will accept something like a
fill
keyword that makes the text grow infinitely if it is smaller than the container. - Optionally, it could accept the βmax-font-sizeβ value that could be used as the upper limit.
- There is no need to specify the lower limit β the initial font size of the element could be used for it (but this is debatable).
- If the
fill
keyword is present, the maximum intrinsic size of that element expands to fill all the available space. - If an upper limit is specified, the maximum intrinsic size is equal to the maximum intrinsic size of the text if it was rendered with that size.
- The minimum intrinsic size of that text is equal to the minimum intrinsic size of the original font size.
There appears to be no circularity involved in the technique (as it works already everywhere), and given the general scope of it will be similar to text-wrap: balance
(headers), it should not pose any performance challenges.
I'll be happy with any feedback, and I encourage browser vendors to prototype this natively: this feature is a very common need, and could be a quick win if we will be able to add it to the Web platform in a way similar to text-wrap: balance|pretty
.
Without a native CSS property, the technique, while is possible, is very cumbersome and requires text duplication which, if implemented incorrectly, could lead to accessibility problems, so having a proper way of achieving this would be great.
@kizu Awesome work! I could see this being an excellent use case for a Web Component to facilitate the extra HTML and hide it in a shadow DOM, as a stopgap until CSS supports this natively. I imagine it would be pretty trivial to create a Vue/React/Angular/Svelte/etc. component version of this as well.
Great writeup on your process and result. Thank you for sharing it!
Something like this might work?
//textfit.js
class TextFit extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
static get observedAttributes() {
return ['max-font-size'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'max-font-size') {
this.render();
}
}
render() {
const maxFontSize = this.getAttribute('max-font-size') || 'infinity';
const content = this.textContent;
this.shadowRoot.innerHTML = `
<style>
:host {
--max-font-size: ${maxFontSize === 'infinity' ? 'infinity * 1px' : maxFontSize};
display: flex;
container-type: inline-size;
--captured-length: initial;
--support-sentinel: var(--captured-length, 9999px);
line-height: 0.95;
margin: 0.25em 0;
}
[aria-hidden] {
visibility: hidden;
}
:not([aria-hidden]) {
flex-grow: 1;
container-type: inline-size;
--captured-length: 100cqi;
--available-space: var(--captured-length);
}
:not([aria-hidden]) > * {
display: block;
--captured-length: 100cqi;
--ratio: tan(atan2(var(--available-space), var(--available-space) - var(--captured-length)));
font-size: clamp(1em, 1em * var(--ratio), var(--max-font-size, infinity * 1px) - var(--support-sentinel));
inline-size: calc(var(--available-space) + 1px);
}
@property --captured-length {
syntax: "<length>";
initial-value: 0px;
inherits: true;
}
</style>
<span>
<span><span>${content}</span></span>
<span aria-hidden="true">${content}</span>
</span>
`;
}
}
customElements.define('textfit', TextFit);
Then you just include the textfit.js in your HTML, and use it like:
<textfit>Your text here</textfit>
<textfit max-font-size="5em">Custom max font size</textfit>
I published a small update in the article: https://kizu.dev/fit-to-width/#accounting-for-optical-sizing β in short, my technique in its previous form did not work for variable fonts with an optical sizing axis.
However, it was not too difficult to fix. For this case, all we need is introduce another step: when we render things for the first time, instead of applying the font-size, we apply it only as a opsz
value, and then repeat it again. The applied opsz
won't be 100% correct, but is good enough, not straying too far from the proper one. And this step is only needed for fonts with opsz
(and could be potentially reused for any other similar cases), so not a perf issue.
@nathanchase Yes, this could be done with custom elements and shadow DOM, although it might be a bit tricky with the way it works: we could want to keep the text itself in the light DOM, so all the styles will be applied as before, so we'll need to use slots. But also we can't put original text into the slot, and duplicate things into the shadow DOM, as then the styles won't apply evenly: we'd probably need to create extra slots, and then duplicate the content in the light DOM, assigning it to different slots. (and also you'd want to use a text-fit
custom element name, as they have to contain at least one dash).
A few more experiments and further thoughts about the limitations of my technique and a potential algorithm based on it.
The main limitation will be that while it works well for a singular relative font-size, it won't be as good when there are nested elements or aspects that use static values or complex calculations.
- If there will be values in
px
, they will break the scaling, as we no longer could scale things proportionally.- It could be possible to work around this with my technique by adding yet another copy of the text. With it, we'd reduce the font-size to
0
and measure the result, getting the remaining static width that could be subtracted from any further calculation for the scaling. - With the browser algorithm, it could also be something that should be possible to implement: either in the same way, rendering things with 0 for any relative units, then subtracting it from the further calculations. Or maybe there could be further optimizations: if, when rendering things, we know that some values, that result in the increase in the inline direction, are static, we could account for them in our calculations without an extra render.
- It could be possible to work around this with my technique by adding yet another copy of the text. With it, we'd reduce the font-size to
- If there are values in
em
like0.5em
or1.5em
on nested elements, things will still scale well, except for the optical sizing, as we will make it static, approximating on the font-size of the original element.- I am not yet sure if there is an easy workaround for this, but maybe? I am not sure if it is possible to achieve in my technique, as we can't easily retrieve the font-sizes of the inner elements to approximate their
opsz
axis, but a native implementation could achieve this easier: when we render the intermediate approximate state, we'll fix the optical sizing for all the participating elements before further scaling.
- I am not yet sure if there is an easy workaround for this, but maybe? I am not sure if it is possible to achieve in my technique, as we can't easily retrieve the font-sizes of the inner elements to approximate their
- The most problematic case: mixed units like
calc(0.5em + 10px)
, or font-sizes withclamp()
.- I don't think there is any venue to accommodate them: they result in a non-linear scaling, each being a new variable, making things much more complicated.
- That said, in my testing, while the results are not good (text does not scale properly), not solving this edge case sounds like a good enough compromise. These kinds of dynamic units are generally used for other attempts at making the text scale to the container, and there is much less need for them inside the text that fits the container via a native feature.
- Given the final result will still use the original font-size as the minimal fallback, it should be acceptable to not get the proper scaling for this edge case.
While these findings complicate the final algorithm a bit (accommodating the static dimensions + a per-element optical sizing freezing), and uncover cases that won't scale perfectly (mixed units and calculations), I still think this algorithm is a viable step forward.
With things like text-wrap: balance
we already incorporate some compromises, preferring to cover the most common cases, and when approaching the text scaling we could do the same.
@kizu For what it's worth, I did successfully create a Vue 3 single file component (.vue) and already have found several uses for it in a project:
<template>
<span class="text-fit">
<span><span>{{ text }}</span></span>
<span aria-hidden="true">{{ text }}</span>
</span>
</template>
<script setup lang="ts">
const props = defineProps<{
text: string
maxFontSize?: string
}>();
const maxfontsize = computed(() => {
return props.maxFontSize || '9rem';
});
</script>
<style scoped>
.text-fit {
--max-font-size: v-bind(maxfontsize);
display: flex;
container-type: inline-size;
width: 100%;
--captured-length: initial;
--support-sentinel: var(--captured-length, 9999px);
& > [aria-hidden] {
visibility: hidden;
}
& > :not([aria-hidden]) {
flex-grow: 1;
container-type: inline-size;
--captured-length: 100cqi;
--available-space: var(--captured-length);
& > * {
--support-sentinel: inherit;
--captured-length: 100cqi;
--ratio:
tan(
atan2(
var(--available-space),
var(--available-space) - var(--captured-length)
)
);
--font-size:
clamp(
1em,
1em * var(--ratio),
var(--max-font-size)
-
var(--support-sentinel)
);
inline-size: var(--available-space);
&:not(.text-fit) {
display: block;
font-size: var(--font-size);
/* stylelint-disable-next-line */
@container (inline-size > 0) {
white-space: nowrap;
}
}
&.text-fit {
--captured-length2: var(--font-size);
font-variation-settings:
"opsz"
tan(
atan2(var(--captured-length2), 1px)
);
}
}
}
}
.text-fit:not(.text-fit *) {
--max-font-size: v-bind(maxfontsize);
margin: 0.25em 0;
line-height: 0.95;
}
@property --captured-length {
syntax: "<length>";
initial-value: 0;
inherits: true;
}
@property --captured-length2 {
syntax: "<length>";
initial-value: 0;
inherits: true;
}
</style>
and usage is just:
<TextFit
text="Text goes here"
max-font-size="28px"
/>