1

I'm trying to have a <div> slide open to the minimum size required to contain its content without anything overflowing.

Here's what I've got so far:

window.onload = function() {
  setTimeout(() => {
    var thing = document.getElementById('thing');
    thing.style.maxWidth = thing.scrollWidth + "px";
    thing.style.maxHeight = thing.scrollHeight + "px";
  }, 1000);
}
body {
  background-color: black;
}

p {
  margin: 40px;
  padding: 0;
  color: yellow;
  background-color: green;
  font-family: monospace;
}

div.expander {
  margin: 0;
  padding: 0;
  max-width: 0;
  max-height: 0;
  overflow: hidden;
  transition:
    max-width 1s,
    max-height 1s;
  border: 1px solid red;
}

hr {
  margin: 0;
  padding: 0;
  border-top: 1px solid blue;
  border-right: none;
  border-bottom: none;
  border-left: none;
}
<div id="thing" class="expander">
  <p>Hello, world!</p>
  <hr>
  <p>Goodbye, world!</p>
</div>

See how neither <p> is wide enough to contain its text without overflowing? That's what I'm trying to prevent. Clearly, scrollHeight is doing what I expect. Why isn't scrollWidth?

lawrence-witt
  • 8,094
  • 3
  • 13
  • 32
Claire Nielsen
  • 1,881
  • 1
  • 14
  • 31

1 Answers1

1

This isn't the full picture, but margin-right (or margin-left) gets ignored in specific scenarios. This is intended block functionality in CSS. From the specs:

'margin-left' + 'border-left-width' + 'padding-left' + 'width' + 'padding-right' + 'border-right-width' + 'margin-right' = width of containing block

If all of the above have a computed value other than 'auto', the values are said to be "over-constrained" and one of the used values will have to be different from its computed value. If the 'direction' property of the containing block has the value 'ltr', the specified value of 'margin-right' is ignored and the value is calculated so as to make the equality true. If the value of 'direction' is 'rtl', this happens to 'margin-left' instead.

In my opinion, this isn't very intuitive behavior and I prefer to avoid margins for block layout where possible (for more reasons than just this). So, you could add a wrapper <div> around your <p> tags and use padding instead. This also enables you to use a border between items instead of adding <hr> to your content. I think the proper semantics could go either way, depending on your real-world usage of this. To quote the MDN docs:

The HTML <hr> element represents a thematic break between paragraph-level elements: for example, a change of scene in a story, or a shift of topic within a section.

Historically, this has been presented as a horizontal rule or line. While it may still be displayed as a horizontal rule in visual browsers, this element is now defined in semantic terms, rather than presentational terms, so if you wish to draw a horizontal line, you should do so using appropriate CSS.

But, while that fixes a few concerns, that doesn't fix everything. The calculated width of the <p> elements is still less than min-content. So, we can force this with min-width: min-content;, or by using a different display value (probably on expander). This changes which algorithms are used for calculating widths under the hood.

Last note before the full example code: max-width and max-height are a decent trick but are really only useful for this kind of thing if you're trying to avoid modifying styles from JS (think :hover or adding and removing an .open class from JS, instead of setting width and height, directly)

setTimeout(() => {
  const expander = document.querySelector('.js-expander');
  expander.style.width = expander.scrollWidth + "px";
  expander.style.height = expander.scrollHeight + "px";
}, 1000);
body {
  background-color: black;
}

.expander {
  display: grid;
  width: 0px;
  height: 0px;
  overflow: hidden;
  border: 1px solid red;
  transition:
    width 1s,
    height 1s;
}

.expander-item {
  padding: 40px;
  font-family: monospace;
  border-bottom: 1px solid blue;
}

.expander-item:last-child { border-bottom: 0px; }

.expander-item > * {
  color: yellow;
  background-color: green;
}

.expander-item > :first-child { margin-top: 0; }
.expander-item > :last-child { margin-bottom: 0; }
<div class="expander js-expander">
  <div class="expander-item">
    <p>Hello, world!</p>
  </div>
  <div class="expander-item">
    <p>Goodbye, world!</p>
  </div>
</div>

UPDATE: Based on comments, I thought I might include how <hr/> might be added back in:

setTimeout(() => {
  const expander = document.querySelector('.js-expander');
  expander.style.width = expander.scrollWidth + "px";
  expander.style.height = expander.scrollHeight + "px";
}, 1000);
body {
  background-color: black;
}

.expander {
  display: grid;
  width: 0px;
  height: 0px;
  overflow: hidden;
  border: 1px solid red;
  transition:
    width 1s,
    height 1s;
}

.expander-group {
  padding: 40px;
  font-family: monospace;
}

.expander-group > * {
  color: yellow;
  background-color: green;
}

.expander-group > :first-child { margin-top: 0; }
.expander-group > :last-child { margin-bottom: 0; }

.expander > hr {
  margin: 0;
  padding: 0;
  border-top: 1px solid blue;
  border-right: none;
  border-bottom: none;
  border-left: none;
}
<div class="expander js-expander">
  <div class="expander-group">
    <p>Hello, world 1!</p>
    <p>Hello, world 2!</p>
  </div>
  <hr>
  <div class="expander-group">
    <p>Goodbye, world!</p>
  </div>
</div>
BCDeWitt
  • 4,540
  • 2
  • 21
  • 34
  • Overall I like it - it's a good starting point towards what I'm ultimately going for, and you've given me a lot of good insight into what's going on inside the box model... but by removing the `hr` and attaching borders to the `.expander-item`s, you've changed my semantics. The blue line was not supposed to be a property of the `div` above it - I later want to stack multiple items without lines between them, or have my `expander` use `flex-direction: row`, etc. That's why I wanted an explicitly independent element - I maybe simplified to a bit too minimum of an example in my initial question? – Claire Nielsen Jan 26 '21 at 19:41
  • Easy enough to add the `
    ` tag back in and this is why I mentioned it "could go either way", but what you just mentioned doesn't sound like semantics to me. So, I'll clarify just in case: If the point is actually to include a thematic break between paragraphs, which just so happens to be displayed as a blue line, I agree that the `
    ` tag may belong between items. If the point is just to "include a blue line", that doesn't belong in the HTML - you can just include multiple `

    ` tags in each `.expander-item` (and maybe "group" would be a better name than "item")

    – BCDeWitt Jan 26 '21 at 20:04
  • It's also worth pointing out that this - possibly just because it's a minimum example - looks like it could be [nested lists](https://stackoverflow.com/questions/2958272/is-there-a-way-to-group-li-elements) – BCDeWitt Jan 26 '21 at 20:10
  • @ClaireNielsen Updated the answer to include how you could add the `
    ` tag back in, if you find that to be appropriate.
    – BCDeWitt Jan 26 '21 at 20:29
  • Landing back on this today and figured I'd drop this link here for more info https://developer.mozilla.org/en-US/docs/Glossary/Semantics#semantics_in_html – BCDeWitt Apr 22 '21 at 15:06