Seph Reed
Seph Reed

Reputation: 10968

Is there a CSS way, or non-reflowing way to minimize the width of an element (without increasing height)?

Below is a photo of iMessage, sorry it's a bit oversized. In the image, you will see that different multi-line messages are different widths. In fact, each one seems to be optimized for minimal width without creating a newline.

enter image description here

The following is some code which achieves this effect very slowly.

// finds the minimum width an element can be without becoming taller
function minimizeWidth(domNode) {
  if (domNode.offsetWidth < 160) { return; }
  const squinchFurther = () => {
    const startHeight = domNode.offsetHeight;
    const startWidth = domNode.offsetWidth;
    if (startWidth === 0) {
      return;
    }

    domNode.style.width = (startWidth - 1) + "px";
    // wait for reflow before checking new size
    requestAnimationFrame(() => requestAnimationFrame(() => {
      // if the height has been increased, go back
      if (domNode.offsetHeight !== startHeight) {
        domNode.style.width = startWidth + "px";
      } else {
        squinchFurther();
      }
    }));
  }
  requestAnimationFrame(() => requestAnimationFrame(squinchFurther));
}

const divs = document.querySelectorAll("div");
for (let i = 0; i < divs.length; i++) {
  minimizeWidth(divs[i]);
}
div {
  box-sizing: border-box;
  display: inline-block;
  max-width: 160px;
  padding: 5px;
  border-radius: 5px;
  margin: 10px;
  background: #08F;
  color: white;
}
<div>Here's some multi line text</div>
<br>
<div>Word</div>
<br>
<div>Crux case a a a a a a a a</div>

Is there any CSS which will do this automatically? If not, is there a way to calculate it in JS without waiting for reflows?

I remember seeing at one point something about a "Reflow Worker" that could be coded with WASM, but I can't find a thing on it right now. If anyone knows what I'm talking about, please share a link.

Upvotes: 3

Views: 593

Answers (4)

Mikepote
Mikepote

Reputation: 6533

Here's my attempt, it seems to work fairly nicely, but it still depends on the width of the frame around the content, so it's not the most maximal solution, but it's close enough I think.

It's just a Flexbox row with a hidden spacer set to 100% width which pushes the text up against the left side of the container as much as it can.

.frame {
  width: 600px;
  border: 1px solid black;
  padding: 8px;
}

.frame.smaller {
  width: 210px;
}

.inner {
  box-sizing: border-box;
  display: inline-block;
  padding: 8px;
  border-radius: 5px;
  background: #08F;
  color: white;

  min-width: 20%;
  flex-shrink: 1;
}

.outer {
  display: flex;
  flex-direction: row;
  width: 100%;
  flex-shrink: 1;
}

.spacer {
  width: 100%;
}
<div class="frame smaller">
  <div class="outer">
    <div class="inner">Here's some multi line text that doesnt go too far</div>
    <div class="spacer"></div>
  </div>
  <br>
  <div class="outer">
    <div class="inner">Some more information on this line which is quite long and runs on for a very long time until it runs out of space and then it should wrap nicely.</div>
    <div class="spacer"></div>
  </div>
  <br>
  <div class="outer">
    <div class="inner">A shorter line...</div>
    <div class="spacer"></div>
  </div>
</div>
<br/><br/>
<div class="frame">
  <div class="outer">
    <div class="inner">Here's some multi line text that doesnt go too far</div>
    <div class="spacer"></div>
  </div>
  <br>
  <div class="outer">
    <div class="inner">Some more information on this line which is quite long and runs on for a very long time until it runs out of space and then it should wrap nicely.</div>
    <div class="spacer"></div>
  </div>
  <br>
  <div class="outer">
    <div class="inner">A shorter line...</div>
    <div class="spacer"></div>
  </div>
</div>

Upvotes: 1

Barni
Barni

Reputation: 57

This is not perfect solution. You have asked about CSS only solution and since there is nothing here I've decided to post it. It is the best you can get from mine perspective.

The concept is based on font-size of the parent. That's why padding and borders should be defined in em unit. If the are not correlated lines will overlap each other.

text-wrap: balance; and box-decoration-break: clone; are key features to position the text and background correctly.

CAVEATS

  • At the right side you can observe "steps" when the lines aren't equal.
  • In HTML there is an extra span.

Docs:

https://developer.mozilla.org/en-US/docs/Web/CSS/text-wrap

https://developer.mozilla.org/en-US/docs/Web/CSS/box-decoration-break

section {
    max-width: 200px;
    font-size: 16px;
}
div {
    display: inline-block;
    margin: 1em;
    color: white;
    text-wrap: balance;
    line-height: 1.7;
}
span {
    border-radius: 0.3em;
    padding: 0.5em;
    background: #08f;
    box-decoration-break: clone;
}
<section>
<div><span>Here's some multi line text</span></div>
<br>
<div><span>Word</span></div>
<br>
<div><span>Crux case a a a a a a a a</span></div>
<br>
<div><span>Even more typed text in here yyy</span></div>
<section>

Upvotes: 1

skyline3000
skyline3000

Reputation: 7913

As far as I can tell this is not possible with CSS alone. The solution below holds each text block in a simple <div> + <span> structure, then uses getBoundingClientRect() to measure the <span>'s width and updates it to be display:block with the correct width.

It looks like there is definitely a max-width which accounts for the line wrapping, that is if "McCormick" or "interesting" were on the previous line the width would be too long. I don't believe I've ever seen messages that extend over ~75% of the screen width. I've set a max-width of 160px for this demo.

Note that there are two for loops so that the widths can be cached so we don't continually read then write to the DOM (and cause multiple reflows).

function updateWidths() {
  const elems = document.querySelectorAll('.inner');
  const len = elems.length;
  const widths = [];

  // Read from the DOM
  for (let i = 0; i < len; i++) {
    widths.push(elems[i].getBoundingClientRect().width);
  }

  // Write to the DOM
  for (let i = 0; i < len; i++) {
    elems[i].style.display = 'block';
    elems[i].style.width = widths[i] + 'px';
  }
}

updateWidths();
.outer {
  margin-top: 10px;
  max-width: 160px;
}

.inner {
  background-color: blue;
  border-radius: 5px;
  color: white;
  padding: 5px;
}
<div class="outer">
  <span class="inner">Yeah, this week at McCormick place apparently</span>
</div>

<div class="outer">
  <span class="inner">Negative, seems interesting tho</span>
</div>

<div class="outer">
  <span class="inner">Some other random message which is a little bit longer than the other messages</span>
</div>

Upvotes: 2

Seph Reed
Seph Reed

Reputation: 10968

I've found a trick that can be used to cause only one reflow, but it would be a bit of a doozy. The basic gist of it is:

// helper fns from npm el-tool
const div = (children: HTMLElement[]) => {
  const out = document.createElement("div");
  children.forEach((child) => out.appendChild(child));
  return out;
}
const span = (text: string) => {
  const out = document.createElement("span");
  out.innerText = text;
  return out;
}


export default function minimalWidthDiv(innerText: string) {
  // split string into words with following spaces included
  const wordEls = innerText.trim().match(/\S+\s+/g).map(span);
  const thinDiv = div(wordEls);
  // set to hidden while computing width to avoid thrashy renders
  thinDiv.style.visibility = "hidden";

  // wait for first render
  requestAnimationFrame(() => requestAnimationFrame(() =>
    const numberOfLines = magicFnThatFindsNumberOfLines();
    const currentWidth = thinDiv.offsetWidth;
    const minimalWidth = currentWidth/numberOfLines;
    let bestWidth = 0;
    // figure out the best width based of the widths of the words
    // if more than two lines, the loop below won't work is most cases
    for (let i = 0; i < wordEls.length && bestWidth < minimalWidth; i++) {
      bestWidth += wordEls[i].offsetWidth;
    }
    // update the width of the thinDiv and make it visible.
    thinEl.style.width = bestWidth + "px";
    thinEl.style.visibility = "";
  ));
  return thinDiv;
}

The trick here is to put all of the words into separate spans so their width can be computed. From there, it's possible to figure out the minimum width without needing a new line.

Upvotes: 0

Related Questions