Margin Collapsing in CSS
In PSPDFKit for Web 2022.1, we announced support for headers and footers when using the PDF Generation component. This allows you to specify custom DOM containers that will be rendered across each page of a generated PDF.
You can see it in action in the embedded view below.
In this blog post, I’ll explore how — while working on the new headers and footers feature — I identified and solved a particular bug. This bug was related to a surprising behavior with margin collapsing in CSS that affects the position and sizing of elements. For context, I’ll begin by explaining the scenario we were facing, and then, I’ll discuss the approach I took while debugging it and the underlying margin collapsing mechanisms that played into this.
Measuring Headers and Footers
The HTML editor of the view above shows the HTML template, along with the resulting PDF. If you look closely at the elements with pspdfkit-header
and pspdfkit-footer
as their id
attributes, notice how their contents are repeated across each page of the generated PDF.
An essential part of this implementation involves measuring the size and position of the elements defined in the template serving as header or footer.
Let’s take the footer for instance. The footer is supposed to be the very last element present in the HTML template that will generate the PDF form. We can use the Element.getBoundingClientRect()
method on the footer to get its height
and bottom
position, and intuitively it might seem like that bottom
position should also match the end of the whole page. However, it turns out there’s more to it than expected.
Take the following snippet:
<html> <body> <!-- ... --> <div id="pspdfkit-footer"> <p class="footer-columns"> <span>Purchase Order</span> <span>Page {{ pageNumber }} of {{ pageCount }}</span> </p> </div> </body> </html>
Here’s the relevant CSS as a reference:
body { margin-block-end: 0; } #pspdfkit-footer { margin-block-start: 2.5rem; } .footer-columns { display: flex; justify-content: space-between; padding-inline: 2.5rem; }
During PDF generation, when positioning the content correctly, we were assuming that, with an unaltered flow layout, the footer would be placed at the end of the DOM.
However, let’s measure for ourselves to see if this is the case:
window.scrollY; // Output: 0 (measurements relative to the top of the viewport) document.querySelector('#pspdfkit-footer').getBoundingClientRect() .bottom; // Output: 1713 document.querySelector('body').getBoundingClientRect().bottom; // Output: 1713 document.querySelector('html').getBoundingClientRect().bottom; // Output: 1721
The bottom of the footer matches the bottom of the body
as expected — in this case, it’s 1,713 pixels. However, the bottom of the root element (<html>
) is 8 pixels below the end of the <body>
. This is unexpected, given that we explicitly set the margin-block-end
of the body
to 0
and there are no custom styles applied to the root element.
In the previous screenshot, we can use the DOM inspector tool highlighting in blue to check the area of the <body>
and see how there’s an 8-pixel-tall empty space at the very bottom of the page (hence the white space before the inspector UI).
In the context of PDF generation, this resulted in an unexpected additional empty space that bled into an additional blank page in the generated PDF. It took me a lot more debugging than what I’m willing to admit to find the culprit of all of this: margin collapse.
What Is Margin Collapse?
Margin collapse is what happens in a CSS flow layout when vertical margins that are in contact with one another combine to form a single margin.
A typical example of this behavior looks something like the following:
Notice how, between the colored boxes, there’s a 30-pixel gap (you can use the browser inspector to check this), and the margin-top: 20px;
declaration of the .box-b
<div>
doesn’t have any visible effect. The browser keeps only the larger margin between them and discards the other one. That larger margin is the one that ends up affecting the layout.
OK, so margin collapsing is something that, from what can be seen in the previous example, affects sibling elements. However, our <div id="pspdfkit-footer">
is the last child of the <body>
, and it doesn’t have a margin defined.
Where are those extra eight pixels between the end of the <body>
and the end of the <html>
element coming from? Believe it or not, it turns out that this gap is the result of the bottom margin of the <p>
element inside the footer.
I didn’t apply a custom margin to it, but the user-agent stylesheet (aka the default styles set by the browser) defines a default top and bottom margin value to <p>
elements. The value of the bottom margin is 1em
, which, in my particular scenario, resulted in the eight pixels of extra space.
There’s a rule of margin collapsing that applies to this particular scenario. It’s the following (from the CSS 2.2 Spec):
“The bottom margin of an in-flow block box with a ‘height’ of ‘auto’ collapses with its last in-flow block-level child’s bottom margin, if:
- the box has no bottom padding, and
- the box has no bottom border, and
- the child’s bottom margin neither collapses with a top margin that has clearance, nor (if the box’s min-height is non-zero) with the box’s top margin.”
To simplify it a bit, the text above is saying that the bottom margin of the last child of an element will be transferred to that parent element unless:
-
The parent element contains a bottom padding.
-
The parent element contains a bottom border.
-
The parent element sets a different
height
thanauto
. -
The child’s bottom margin doesn’t collapse with a top margin with clearance.
-
The child’s bottom margin doesn’t collapse with the top margin of the parent element (if the
min-height
of the parent element is different than0
).
Returning to our scenario, the bottom margin of the paragraph gets transferred to the footer (its parent), but it turns out that it again gets transferred to the <body>
due to the exact same rule — the footer has no bottom margin, it’s the last child of <body>
, and all the previous conditions apply — and that’s why it results in the gap with the end of the root element, and no gap between the footer and the end of <body>
.
Let’s see what would’ve happened if the <body>
contained a red bottom border of 1px
(i.e. border-bottom: 1px solid red;
):
window.scrollY; // Output: 0 (measurements relative to the top of the viewport) document.querySelector('#pspdfkit-footer').getBoundingClientRect() .bottom; // Output: 1713 document.querySelector('body').getBoundingClientRect().bottom; // Output: 1722 document.querySelector('html').getBoundingClientRect().bottom; // Output: 1722
Let’s bring back our initial measurements to avoid needing to scroll back to the top of this post:
window.scrollY; // Output: 0 (measurements relative to the top of the viewport) document.querySelector('#pspdfkit-footer').getBoundingClientRect() .bottom; // Output: 1713 document.querySelector('body').getBoundingClientRect().bottom; // Output: 1713 document.querySelector('html').getBoundingClientRect().bottom; // Output: 1721
Notice that although the bottom of the footer is still at 1,713 pixels as before, the <body>
is now at 1,722 pixels, matching the end of the root <html>
element as well. Unlike before, it’s 1,722 pixels instead of 1,721 pixels because we have the extra 1-pixel red bottom border now.
There’s now a gap between the end of the footer and the end of the <body>
that wasn’t there before because the bottom margin of the paragraph element still collapses from the footer to the <body>
, and now the <body>
can’t collapse it again due to the new border.
Notice in this screenshot how there’s no gap between the end of the <body>
and the end of the page. This is visible by the lack of white space between the page and the inspector UI.
Suppressing Margin Collapse
So, the border bottom thing was a nice experiment, but is there a way to prevent the bottom margin of the paragraph inside the footer from collapsing?
When I saw this problem, it occurred to me on a whim to add the overflow: hidden
declaration to the footer as a way to prevent anything from “bleeding out” from the footer, and it worked! So I decided to look into why.
I found my answer in the overflow
entry on MDN:
“Specifying a value other than
visible
(the default) orclip
creates a new block formatting context. This is necessary for technical reasons…”
So, it creates a new block formatting context. And in the CSS spec, there’s another condition that must be met for margins to collapse:
“Two margins are adjoining if and only if:
- both belong to in-flow block-level boxes that participate in the same block formatting context.”
Essentially, it’s enough for the footer to not be in the same block formatting context to suppress margin collapse. It bugs me a bit that although using overflow
works, it’s only because the creation of a new block formatting context is done for technical reasons. However, it turns out there are a lot of ways to create block formatting contexts; here’s a comprehensive list.
One option I like, because of the semantics, is the display: flow-root;
declaration. According to the spec, its entire purpose is to establish a new block formatting context for its contents.
Let’s take some measurements again, this time adding the display: flow-root;
declaration to the footer:
window.scrollY; // Output: 0 (measurements relative to the top of the viewport) document.querySelector('#pspdfkit-footer').getBoundingClientRect() .bottom; // Output: 1729 document.querySelector('body').getBoundingClientRect().bottom; // Output: 1729 document.querySelector('html').getBoundingClientRect().bottom; // Output: 1729
They all match. 🎉 This is exactly what we wanted to achieve for PDF Generation: ensuring any margin specified inside the header/footer containers isn’t missing from our measurements.
You might wonder why the final height is now 1,729 pixels instead of 1,721, as before. The reason is that the top margin of the paragraph inside the footer was also collapsing with the top margin set to the footer element, and now, due to the contents of the footer residing in a new block formatting context, those top margins no longer collapse. Additionally, we need to account for the 8 pixels of the top margin (i.e. margin-block-start
) of the paragraph inside the footer.
Conclusion
What I presented in this blog post is only one of the potential scenarios where margins collapse in CSS. There are more cases you need to watch out for, and it can get a little more complicated when dealing with negative margins (yes, they also collapse). I recommend this excellent article by Josh W. Comeau that includes some interactive examples to help visualize margin collapse.
If you’d like to try out PDF Generation with PSPDFKit, check out our API, Processor, and Web server-backed products.