CSS Module 4 – The box model, sizing and overflow
Sizing: percentages, intrinsic sizes and ratios
Size CSS boxes with width, height and limits, see why height: 100% often fails, use min-content and fit-content, and stop layout shift with aspect-ratio.
What you will learn
- Size boxes with width, height, min and max limits and their logical equivalents
- Explain why a percentage height needs a definite parent height, and what to use instead
- Use the intrinsic keywords min-content, max-content and fit-content
- Prevent layout shift by reserving space for media with aspect-ratio and image dimensions
Before you start
What you’ll learn · Before you start
What you will learn
- Size boxes with width, height, min and max limits and their logical equivalents
- Explain why a percentage height needs a definite parent height, and what to use instead
- Use the intrinsic keywords min-content, max-content and fit-content
- Prevent layout shift by reserving space for media with aspect-ratio and image dimensions
Before you start
On this page
A box gets its size from one of three places. You can give it a length or a percentage (width: 320px,
width: 50%), which is extrinsic sizing. It can take its size from its content, which is intrinsic sizing:
min-content is as narrow as the content can wrap, max-content as wide as it is on one line, and fit-content
uses the space there is without going below the one or above the other. Or it can follow a ratio: aspect-ratio
and the width and height attributes of an image fix the height from the width. Each has a trap: percentage
heights that do nothing, content that overflows narrow screens, and images that push the page down when they arrive.
This lesson measures each trap in Chromium 153, Firefox 155 and WebKit 26.6, and shows the fix.
width, height and their limits
Without a size, a block in normal flow fills the width of its container, and its height is whatever its content
needs. A percentage width is a share of the containing block’s width. The limits min-width, max-width,
min-height and max-height clamp the size instead of fixing it, which is usually what a layout needs: when the
limits disagree, the minimum wins.
The logical names follow the direction of the text: inline-size is the width in English, block-size the
height, and min-inline-size, max-block-size and the rest work the same way. They are what this lesson’s examples
use, because they keep working in vertical and right-to-left text.
/* Limits instead of fixed sizes: the box can still follow its content and its container. */
.prose {
max-inline-size: 65ch; /* never wider than about 65 characters of its font */
}
.hero {
min-block-size: 320px; /* at least 320px tall, and taller when the content needs it */
}
img,
video {
max-inline-size: 100%; /* never wider than the container */
block-size: auto; /* keep the ratio when the width shrinks */
} A limit keeps the layout flexible. max-inline-size: 65ch keeps lines of text to roughly 65 characters on a wide screen
and lets them shrink on a phone, and min-block-size: 320px gives a banner its height while letting it grow when the
text inside needs more. The image rule is the one to put in every stylesheet: an image is never wider than its
container and keeps its proportions as it shrinks.
Why height: 100% often does nothing
A percentage height is a share of the containing block’s height, and only works when that height is definite, known without laying out the content. Five parents, each with one child:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>When height: 100% works</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.parent { margin-block-end: 8px; background: #eee; }
.full { block-size: 100%; background: #dde; }
#auto-height .parent { } /* height: auto, the default */
#fixed-height .parent { block-size: 200px; }
#min-height .parent { min-block-size: 200px; }
#flex .parent { min-block-size: 200px; display: flex; } /* the child stretches: no height on it */
#grid .parent { min-block-size: 200px; display: grid; }
</style>
</head>
<body>
<div id="auto-height"><div class="parent"><div class="full">height: 100%</div></div></div>
<div id="fixed-height"><div class="parent"><div class="full">height: 100%</div></div></div>
<div id="min-height"><div class="parent"><div class="full">height: 100%</div></div></div>
<div id="flex"><div class="parent"><div>no height set</div></div></div>
<div id="grid"><div class="parent"><div>no height set</div></div></div>
<script>
for (const id of ['auto-height', 'fixed-height', 'min-height', 'flex', 'grid']) {
const parent = document.querySelector(`#${id} .parent`);
console.log(`${id}: parent ${parent.offsetHeight}px tall, child ${parent.firstElementChild.offsetHeight}px tall`);
}
</script>
</body>
</html> | Parent | Parent’s height | Child’s height |
|---|---|---|
| no height | 24 px | 24 px |
height: 200px |
200 px | 200 px |
min-height only |
200 px | 24 px |
min-height, flex |
200 px | 200 px |
min-height, grid |
200 px | 200 px |
In the first row 100% acted as auto, and the flex and grid children had no height of their own at all. The row
with only min-height is the classic surprise. The parent is visibly 200 px tall, but only because its minimum says so; its
height is still auto, so the child’s percentage has nothing to resolve against and falls back to auto. All three
engines agreed. The fix is usually not a percentage at all: flex and grid items stretch to fill their line by
default, so a parent that is a flex or grid container gives its children its full height. For a section that should
fill the screen, set the minimum on the section itself; the viewport units lesson covers which unit to use on
phones.
Intrinsic sizes: min-content, max-content and fit-content
Intrinsic sizes come from the content:
min-contentis the narrowest the content can be without overflowing: the width it would need if text wrapped at every opportunity, so the longest word (or the widest image) decides.max-contentis the width the content needs with no wrapping at all: the whole text on one line.fit-contentis the available space, but never less thanmin-contentand never more thanmax-content.
An “Add to basket” button with 16 px of horizontal padding, in 16px Arial, set to each keyword in a narrow and a wide space:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>min-content, max-content and fit-content</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.space { margin-block-end: 8px; }
.narrow { inline-size: 90px; }
.wide { inline-size: 640px; }
button { font: inherit; padding: 8px 16px; border: 1px solid #555; display: block; }
.min { inline-size: min-content; }
.max { inline-size: max-content; }
.fit { inline-size: fit-content; }
</style>
</head>
<body>
<div class="space narrow">
<button class="min">Add to basket</button><button class="max">Add to basket</button><button class="fit">Add to basket</button>
</div>
<div class="space wide">
<button class="min">Add to basket</button><button class="max">Add to basket</button><button class="fit">Add to basket</button>
</div>
<script>
const px = (n) => Math.round(n * 100) / 100; // text is measured in fractions of a pixel
for (const space of document.querySelectorAll('.space')) {
const widths = [...space.querySelectorAll('button')].map((b) => `${b.className} ${px(b.getBoundingClientRect().width)}px`);
console.log(`${space.offsetWidth}px of space: ${widths.join(', ')}`);
}
</script>
</body>
</html> | Available | min-content | max-content | fit-content |
|---|---|---|---|
| 90 px of space | 81.14 px | 131.84 px | 90 px |
| 640 px of space | 81.14 px | 131.84 px | 131.84 px |
min-content is the width of “basket” plus the padding and border, max-content the whole label on one line, which
overflows the 90 px space. In 90 px of space, fit-content gave the button exactly the space, with its text on two lines; in 640 px it stopped at
131.84 px. Firefox measured 81.15 and 131.87 px for the same text, a few hundredths of a pixel more, because each
engine measures the glyphs of the font in its own way; another font or a translation gives other numbers and the same
rules. That is why intrinsic sizes are worth using: they follow the real text instead of a guess.
What fit-content does as the space grows
Text description of the diagram
The diagram follows the lesson's "Add to basket" button, whose min-content width is 81 px and max-content width 132 px, as the space available to it grows.
- At the top is the rule: width: fit-content gives the available space, but at least the min-content width and at most the max-content width.
- With 60 px of space, less than min-content, the button is 81 px wide, as wide as its longest word, and it overflows the space.
- With more space, 120 px, the button takes exactly the space, 120 px, and its text wraps to fit.
- With even more space, 280 px, the button stops at 132 px: its text on one line and no wider than that.
The rule as a program, with the two widths Chromium measured:
// fit-content (CSS Box Sizing 3): use the space that is available, but never less than min-content and never more
// than max-content. These two are the widths the "Add to basket" button measured in Chromium with 16px Arial and
// 16px of padding on each side; another font gives other numbers, and the same rule.
const button = { minContent: 81.14, maxContent: 131.84 };
function fitContent({ minContent, maxContent }, available) {
return Math.min(maxContent, Math.max(minContent, available));
}
for (const available of [60, 90, 120, 280, 640]) {
const width = fitContent(button, available);
const note = width > available ? 'wider than the space: it overflows' : width === available ? 'fills the space' : 'shrinks to its text';
console.log(`${String(available).padStart(3)}px of space -> ${width}px (${note})`);
} Output
60px of space -> 81.14px (wider than the space: it overflows) 90px of space -> 90px (fills the space) 120px of space -> 120px (fills the space) 280px of space -> 131.84px (shrinks to its text) 640px of space -> 131.84px (shrinks to its text)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node fit_content.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
fit-content is the keyword to reach for when a block should hug its content but still wrap on a small screen, such
as a heading with a background that ends where the text ends. max-content never wraps, so keep it for short text
whose length you know:
/* A heading whose background hugs its text, centred, and allowed to wrap on a narrow screen. */
.section-title {
inline-size: fit-content;
margin-inline: auto;
padding: 4px 12px;
background: #fde68a;
}
/* max-content never wraps, so on a narrow screen it overflows: use it only for short, known text. */
.price-tag {
inline-size: max-content;
} min-content, max-content and the fit-content keyword are Baseline widely available. Two neighbours are not. The
fit-content() function, which takes a limit as in fit-content(300px), works only for grid tracks; no engine
supports it for width or height. The stretch keyword, which fills the containing block like a block’s auto
width, has limited availability: Chromium and Safari support it, Firefox does not yet.
Ratios: aspect-ratio and image dimensions
aspect-ratio: 16 / 9 makes the height follow the width, so the box keeps its shape at any size. It works on any
box, and it is how video frames, map placeholders and card images keep their shape on every screen.
Images need it most, because the browser does not know an image’s size until the file arrives. While it is loading,
an image without dimensions has no height, and when it appears everything below it jumps down. The width and
height attributes fix that: the HTML standard tells browsers to turn them into aspect-ratio: auto 800 / 600,
which reserves the right height once the CSS lets the width follow the container and sets block-size: auto. The two
pages below differ only in those attributes. Each shows a decorative banner, 800 by 600 px, that arrives half a second after the page, on a phone
screen of 360 by 740 px:
Without width and height · image-unsized.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>An image without width and height</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
img { display: block; max-inline-size: 100%; block-size: auto; }
p { margin: 0; block-size: 96px; } /* four lines of 24px */
</style>
</head>
<body>
<img id="banner" alt="">
<p>The text under the banner: four lines of 24px each, so the paragraph is 96px tall whatever the font.</p>
<script>
const img = document.getElementById('banner');
let score = 0;
const measured = PerformanceObserver.supportedEntryTypes.includes('layout-shift');
if (measured) {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) if (!entry.hadRecentInput) score += entry.value;
}).observe({ type: 'layout-shift', buffered: true });
}
console.log(`before the banner arrives: ${img.offsetWidth} x ${img.offsetHeight}px`);
img.addEventListener('load', () => setTimeout(() => {
const shift = measured ? `layout shift score ${score.toFixed(4)}` : 'this browser does not report layout shifts';
console.log(`after it arrives: ${img.offsetWidth} x ${img.offsetHeight}px; ${shift}`);
}, 500));
// A decorative banner (800 x 600, so alt="") arrives half a second after the page, like one on a slow connection.
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><rect width="800" height="600" fill="#bbc"/></svg>';
setTimeout(() => { img.src = `data:image/svg+xml,${encodeURIComponent(svg)}`; }, 500);
</script>
</body>
</html> With width and height · image-sized.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>An image with width and height</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
img { display: block; max-inline-size: 100%; block-size: auto; }
p { margin: 0; block-size: 96px; } /* four lines of 24px */
</style>
</head>
<body>
<img id="banner" alt="" width="800" height="600">
<p>The text under the banner: four lines of 24px each, so the paragraph is 96px tall whatever the font.</p>
<script>
const img = document.getElementById('banner');
let score = 0;
const measured = PerformanceObserver.supportedEntryTypes.includes('layout-shift');
if (measured) {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) if (!entry.hadRecentInput) score += entry.value;
}).observe({ type: 'layout-shift', buffered: true });
}
console.log(`before the banner arrives: ${img.offsetWidth} x ${img.offsetHeight}px`);
img.addEventListener('load', () => setTimeout(() => {
const shift = measured ? `layout shift score ${score.toFixed(4)}` : 'this browser does not report layout shifts';
console.log(`after it arrives: ${img.offsetWidth} x ${img.offsetHeight}px; ${shift}`);
}, 500));
// A decorative banner (800 x 600, so alt="") arrives half a second after the page, like one on a slow connection.
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><rect width="800" height="600" fill="#bbc"/></svg>';
setTimeout(() => { img.src = `data:image/svg+xml,${encodeURIComponent(svg)}`; }, 500);
</script>
</body>
</html> | Page | Before it arrives | After | Shift score |
|---|---|---|---|
| no attributes | 0 × 0 px | 360 × 270 px | 0.0947 |
| width and height | 360 × 270 px | 360 × 270 px | 0 |
All three engines reserved the 360 × 270 px box for the page with the attributes, and none for the other; the shift scores are Chromium’s, because only Chromium reports layout shifts to the page, through the Layout Instability API, and its score can be worked out by hand: the moved paragraph covered 192 of the screen’s 740 rows (its 96 px before and after the move), which is an impact fraction of 0.2595, and it moved 270 px of the screen’s larger side of 740 px, a distance fraction of 0.3649.
// The layout shift score of one frame (Layout Instability API): impact fraction x distance fraction.
// The page: a 360 x 740 px phone screen, a banner with no width and height attributes at the top, and a 96px
// paragraph under it. Before the banner arrives it takes no space; then it is 270px tall (800 x 600 scaled to 360).
const viewport = { width: 360, height: 740 };
const paragraph = { height: 96 };
const before = { top: 0, bottom: paragraph.height }; // where the paragraph was
const after = { top: 270, bottom: 270 + paragraph.height }; // where the banner pushed it
// The impact region is every point the moved paragraph covered before or after the shift (inside the viewport).
const clip = (r) => ({ top: Math.max(0, r.top), bottom: Math.min(viewport.height, r.bottom) });
const [a, b] = [clip(before), clip(after)];
const overlap = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
const impactArea = (a.bottom - a.top + b.bottom - b.top - overlap) * viewport.width;
const impactFraction = impactArea / (viewport.width * viewport.height);
// The distance fraction is how far it moved, divided by the larger side of the viewport.
const distanceFraction = Math.min(1, (after.top - before.top) / Math.max(viewport.width, viewport.height));
console.log(`impact fraction ${impactFraction.toFixed(4)}`);
console.log(`distance fraction ${distanceFraction.toFixed(4)}`);
console.log(`layout shift ${(impactFraction * distanceFraction).toFixed(4)} (with width and height set: 0)`); Output
impact fraction 0.2595 distance fraction 0.3649 layout shift 0.0947 (with width and height set: 0)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node layout_shift.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Google’s Core Web Vitals call a page’s Cumulative Layout Shift good at 0.1 or less and poor above 0.25, so this one
banner uses almost the whole budget of a page. Two attributes remove it. For an image whose shape should not depend
on the file, set the ratio in CSS and let object-fit crop the photo into it:
/* A card image that keeps a 4:3 box whatever the photo's own shape: the box is reserved before the file arrives. */
.card img {
display: block;
inline-size: 100%;
block-size: auto;
aspect-ratio: 4 / 3;
object-fit: cover; /* fill the box and crop the overflow, instead of stretching the photo */
} Keeping content inside narrow containers
Two rules prevent most overflow on phones. Images get max-inline-size: 100% and block-size: auto, as above. And
flex and grid items get a smaller minimum when they hold long words: their automatic minimum width is based on their
min-content width, so a long URL, a code sample or a wide table keeps the item from shrinking and pushes it out of
its container. min-inline-size: 0 on the item lets it shrink. The overflow lesson next in this module measures that
case with a URL in a chat message, and the flexbox lessons explain the minimum in full.
Practise: size a product card
Exercise · Medium · JavaScript
Size a product card with a 4:3 image and a fitted button
A product card is box-sizing: border-box with 16 px of padding and a 1 px border on each side, and it fills its container. Its image fills the card's content width and keeps a 4:3 ratio, and its "Add to basket" button is width: fit-content, with a min-content width of 81.14 px and a max-content width of 131.84 px. Write three functions in card.mjs and export them. All lengths are in px.
The first function, fitContent({ minContent, maxContent }, available), returns the width fit-content gives: the available space, but never less than minContent and never more than maxContent.
The second, reservedHeight(width, image), returns the height a browser reserves for an image of that width before the file arrives. image may have aspectRatio (width ÷ height, from CSS aspect-ratio), and attrWidth and attrHeight (the HTML width and height attributes). The CSS ratio wins when it is there; otherwise both attributes give the ratio; with neither, nothing is reserved, so the answer is 0.
The third, productCard(containerWidth), returns { contentWidth, imageHeight, buttonWidth } for the card described above, with the image's ratio set by CSS (aspect-ratio: 4 / 3).
The sample tests import the three functions from card.mjs and run in your browser.
Starter code · card.mjs
/** The width that `width: fit-content` gives in `available` px of space. */
export function fitContent({ minContent, maxContent }, available) {
// Replace this line with your code.
return available;
}
/** The height reserved for an image `width` px wide before its file arrives. */
export function reservedHeight(width, image) {
// Replace this line with your code.
return 0;
}
/** The sizes inside the lesson's product card in a container `containerWidth` px wide. */
export function productCard(containerWidth) {
// Replace this line with your code.
return { contentWidth: containerWidth, imageHeight: 0, buttonWidth: 0 };
} The sample tests · card.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { fitContent, reservedHeight, productCard } from './card.mjs';
const button = { minContent: 81.14, maxContent: 131.84 };
test('fit-content takes the space between min-content and max-content', () => {
assert.equal(fitContent(button, 90), 90);
assert.equal(fitContent(button, 120), 120);
});
test('fit-content never goes below min-content or above max-content', () => {
assert.equal(fitContent(button, 60), 81.14);
assert.equal(fitContent(button, 280), 131.84);
assert.equal(fitContent(button, 81.14), 81.14);
assert.equal(fitContent(button, 131.84), 131.84);
});
test('width and height attributes reserve the ratio of the image', () => {
assert.equal(reservedHeight(360, { attrWidth: 800, attrHeight: 600 }), 270);
assert.equal(reservedHeight(300, { attrWidth: 1600, attrHeight: 900 }), 168.75);
});
test('a CSS aspect-ratio wins over the attributes', () => {
assert.equal(reservedHeight(360, { aspectRatio: 16 / 9, attrWidth: 800, attrHeight: 600 }), 202.5);
assert.equal(reservedHeight(280, { aspectRatio: 4 / 3 }), 210);
});
test('with no ratio at all, nothing is reserved', () => {
assert.equal(reservedHeight(360, {}), 0);
assert.equal(reservedHeight(360, { attrWidth: 800 }), 0);
});
test('the card at 280, 400 and 640 px', () => {
assert.deepEqual(productCard(280), { contentWidth: 246, imageHeight: 184.5, buttonWidth: 131.84 });
assert.deepEqual(productCard(400), { contentWidth: 366, imageHeight: 274.5, buttonWidth: 131.84 });
assert.deepEqual(productCard(640), { contentWidth: 606, imageHeight: 454.5, buttonWidth: 131.84 });
});
test('in a very narrow card the button first fills the line, then overflows at min-content', () => {
assert.deepEqual(productCard(120), { contentWidth: 86, imageHeight: 64.5, buttonWidth: 86 });
assert.deepEqual(productCard(100), { contentWidth: 66, imageHeight: 49.5, buttonWidth: 81.14 });
}); A hint
fitContent is Math.min(maxContent, Math.max(minContent, available)). For reservedHeight, work out the ratio first (the CSS one, else attrWidth / attrHeight when both are there) and divide the width by it. In productCard, border-box means the content width is the container minus 2 × (16 + 1).
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Interview questions
Fresher · What do min-content and max-content mean, and when would you use fit-content?
Both are intrinsic sizes, worked out from the content instead of the container. min-content is the narrowest a box
can be without its content overflowing, the width it would need if text wrapped at every opportunity, so the longest
word or the widest image sets it. max-content is the width the content takes with no wrapping at all, for text the whole line. fit-content uses
the available space but stays between the two, which suits blocks that should hug short content and still wrap on a
narrow screen: a heading with a background, a badge, a tooltip. In a 90 px space, a button that is 81 px at
min-content and 132 px at max-content becomes 90 px wide with fit-content. max-content alone is risky for text
that can grow, because it never wraps and overflows.
Key takeaways
- Prefer limits (
max-inline-size,min-block-size) to fixed sizes; when a minimum and a maximum disagree, the minimum wins. - A percentage height needs a definite parent height; a
min-heightis not one. Let flex or grid stretch the child instead. min-contentis as narrow as the longest word,max-contentnever wraps, andfit-contentis the available space clamped between them.- The
fit-content()function works only in grid tracks, and thestretchkeyword is not in Firefox yet. - Give every image
widthandheightattributes, plusmax-inline-size: 100%; block-size: autoin CSS, so its box is reserved before the file arrives; the lesson’s banner cost a layout shift of 0.0947 without them. - Flex and grid items do not shrink below their min-content width: give them
min-inline-size: 0when they hold long words.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- CSS Box Sizing Module Level 3: intrinsic sizes and the sizing keywords (W3C CSS Working Group)
- CSS Box Sizing Module Level 3: definite and indefinite sizes (W3C CSS Working Group)
- CSS Box Sizing Module Level 4: aspect-ratio and the stretch keyword (W3C CSS Working Group)
- CSS 2: the height property and percentage heights (W3C CSS Working Group)
- CSS 2: minimum and maximum widths (W3C CSS Working Group)
- CSS Logical Properties and Values Level 1: logical height and logical width (W3C CSS Working Group)
- HTML Standard: attributes for embedded content and images (WHATWG)
- Layout Instability API: layout shift value (W3C Web Performance Working Group)
- Cumulative Layout Shift (CLS) (Google)
- CSS Flexible Box Layout Module Level 1: automatic minimum size of flex items (W3C CSS Working Group)
- CSS Images Module Level 3: the object-fit property (W3C CSS Working Group)
- aspect-ratio (MDN Web Docs) (Mozilla)
- Web features explorer: stretch (W3C WebDX Community Group)
- Web features explorer: fit-content() (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress