-
Notifications
You must be signed in to change notification settings - Fork 660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[css-fonts-4] Feature for making text always fit the width of its parent #2528
Comments
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 Something like 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 |
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. |
True! (also eg letter-spacing)
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:
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.
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: None of the above implementations causes any noticeable performance issue. And: The latter is a general lib. |
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 |
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:
|
Where are you getting |
Here's the definition of 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 For the desired capability we would need new keywords or functions indeed. |
Yep 😀 |
No one has mentioned the SVG 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 |
@faceless2 wrote:
That would be sensible (with |
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 |
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.
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 The list of things a potential solution for “fit to width” text should handle the following, in my opinion:
|
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 |
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 started a Codepen example to help solve this: https://codepen.io/nathanchase/pen/rNKqYoX Could we somehow utilize |
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:
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 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 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 @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 |
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.
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 |
@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"
/> |
This would be very useful and I'm missing it in almost every project. I guess the most important use-case is if you have a fixed rectangle and want to fit the text inside. For example a stage component that takes the whole viewport with an image. Now if you want to fit a headline for that stage inside a 60vw x 60vh rectangle with optimal font-size you shouldn't have to resort to JS. Perhaps the algorithm could be optimized to avoid being too expensive. Perhaps take two font-sizes apart and quickly narrow it down until it matches the height by halving the range. Considering there's video decoding etc. in today's browsers I wonder if it'd really by that much of a performance hog. Perhaps the amount of HTML that can be inside such an element could be limited somehow. I don't think we'd need tables etc. Simple formatted text with bold italic and so on would probably be sufficient for many cases and reduce the strain on the browser. |
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".
The text was updated successfully, but these errors were encountered: