w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.52k stars 673 forks source link

[css-fonts-4] Feature for making text always fit the width of its parent #2528

Open tobireif opened 6 years ago

tobireif commented 6 years ago

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".

litherum commented 6 years ago

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

tobireif commented 6 years ago

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.

timothyis commented 6 years ago

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!

tobireif commented 6 years ago

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).

SergeyMalkin commented 6 years ago

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.

tobireif commented 6 years ago

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).

tobireif commented 6 years ago

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.

litherum commented 6 years ago

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.

tobireif commented 6 years ago

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.

tobireif commented 6 years ago

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.

tobireif commented 6 years ago

(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.)

tobireif commented 6 years ago

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);
jonjohnjohnson commented 6 years ago

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?

tomhodgins commented 6 years ago

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.

tobireif commented 6 years ago

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 .)

tobireif commented 6 years ago

(I was replying to @jonjohnjohnson , just so there's no misunderstanding @tomhodgins 😀)

tobireif commented 6 years ago

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".

Crissov commented 3 years ago

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.

tobireif commented 3 years ago

For the desired capability we would need new keywords or functions indeed.

Yep 😀

faceless2 commented 3 years ago

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.

tobireif commented 3 years ago

@faceless2 wrote:

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 [inside a box]

That would be sensible (with font size as default).

Crissov commented 3 years ago

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.)

jimmy-guo commented 2 years ago

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.

kizu commented 1 year ago

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:

  1. It should work only inside an element with size or inline-size containment.
  2. 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 the font-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.
  3. Should work with multi-line text:
    • If multiple lines of text are present (with hard-breaks, like with <br/> or in pre 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 the text-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.
kizu commented 1 year ago

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 :)

brandonmcconnell commented 1 year ago

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%.

sarajw commented 1 year ago

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.

nathanchase commented 1 year ago

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?

kizu commented 4 months ago

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:

  1. The new property will accept something like a fill keyword that makes the text grow infinitely if it is smaller than the container.
  2. Optionally, it could accept the “max-font-size” value that could be used as the upper limit.
  3. 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).
  4. If the fill keyword is present, the maximum intrinsic size of that element expands to fill all the available space.
  5. 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.
  6. 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.

nathanchase commented 4 months ago

@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!

nathanchase commented 4 months ago

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>
kizu commented 4 months ago

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).

kizu commented 3 months ago

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 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.

nathanchase commented 3 months ago

@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"
/>
rgpublic commented 1 month ago

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.

LeaVerou commented 2 weeks ago

This came up again today: https://bsky.app/profile/swyx.io/post/3lb2x53wuwc2y

I think it's pretty obvious at this point that the use cases are vast. I've personally come across them numerous times. Can we brainstorm potential ways to make this possible with CSS in a small (ideally fixed) number of passes? UAs are at an advantage here because they have access to font metrics that author code doesn’t.

Naively, it seems like something like this should converge pretty fast, and would only need more than 2 passes on fonts with some very elaborate optical sizing axes:

  1. Render the line at font-size = _targetwidth / _number_of_visible_characters_inline as a crude first approximation.
  2. Measure its width and figure out the ratio of that to the target line width.
  3. If the ratio is != 1, scale the font size by the same ratio.
  4. Repeat 2 until the ratio is 1

For monospace fonts you’re done at 1. For fonts with no optical sizing axis, you need 2 passes. Even for fonts with an elaborate optical sizing axis, it’s extremely unlikely to need more than 3 passes. It would take an optical sizing axis designed specifically for this purpose to take many passes or even hang a browser. To prevent hangs due to maliciously designed fonts, we could define that this process never does more than 5 passes — if you get to that point and the ratio is still != 1, you just scale what you have and don't apply any further optical sizing adjustments (i.e. the optical sizing axis is still set to the previous value).

As an additional performance optimization, the UA could detect whether the font is monospace and/or has an optical sizing axis and not even attempt additional passes in these cases.

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.

We could define that this is only possible when text-wrap-mode is nowrap (or force it to compute to nowrap if this feature is used), at least if no min/max font sizes are provided. What does it even mean that the line should fit AND that it wraps in that case?


While even just a font-size adjustment would be a major help for these use cases, I want to raise the issue that to do this well, you want to adjust weight in addition to font-size so that the strokes of the end result appear similar. Here's a segment of one of my old talks about this where you can also see how painful it is to do manually.

image image

I have also seen designs where spacing is also adjusted.

So ideally, we'd want to design a syntax that could support adjusting multiple font aspects in the future, even if the MVP is just a font-face adjustment. This is not as simple as "adjust font-weight based on the adjustment in font-size" because adjusting the font-weight also adjusts the font size.