CSS Module 9 – Normal flow, positioning and stacking
Normal flow and the display property
How block and inline boxes flow, the two-value display syntax, inline-block and the gap under images, floats with shape-outside, and hiding content properly.
What you will learn
- Explain how block and inline boxes are laid out in normal flow and how display changes an element's role
- Use the two-value display syntax, such as block flow or inline flex, to set the outer and inner display types
- Predict when floats, positioned boxes and flex items are blockified
- Remove the gap under inline images and give badges a size with inline-block
- Wrap text around a floated pull quote along a circle with shape-outside
- Choose between display, visibility, the hidden attribute and content-visibility to hide content correctly
Before you start
What you’ll learn · Before you start
What you will learn
- Explain how block and inline boxes are laid out in normal flow and how display changes an element's role
- Use the two-value display syntax, such as block flow or inline flex, to set the outer and inner display types
- Predict when floats, positioned boxes and flex items are blockified
- Remove the gap under inline images and give badges a size with inline-block
- Wrap text around a floated pull quote along a circle with shape-outside
- Choose between display, visibility, the hidden attribute and content-visibility to hide content correctly
Before you start
On this page
Normal flow is how a browser lays out boxes when nothing else is asked for. Block boxes, such as paragraphs and
headings, stack from top to bottom, each as wide as its container. Inline boxes, such as links and strong text, sit
side by side in lines of text and wrap onto the next line when one is full. The display property decides which of
these roles an element plays, and it answers two questions at once: the outer type, block or inline, says how
the box sits among its siblings, and the inner type, such as flow, flex or grid, says how it lays out its own
children. So inline-flex means inline flex. This lesson measures each role in Chromium 153, Firefox 155 and WebKit
26.6, then covers inline-block and the gap under images, floats that wrap text along a circle, and the right way to
hide content.
Block and inline boxes
In normal flow every box is either block-level or inline-level. A block-level box starts on a new line and, unless
it has a width of its own, fills the inline size of its container. Blocks stack one after another in the block
direction, top to bottom in English, and their vertical margins can collapse, as the
margin collapse lesson measured. An inline-level box goes into a line
box, one after another along the line; when the line is full, the rest moves to the next line, and an inline box can
be split across two lines. On a non-replaced inline box such as a span, width and height do nothing, vertical
margins don’t move anything, and vertical padding is painted over the lines around it without pushing them apart.
Images and other replaced elements are inline too, but they keep the size you give them.
When text sits directly inside a block next to block children, the browser wraps it in an anonymous block box, so every block container ends up holding either only blocks or only lines.
The explorer gives the same two span elements, each with width: 120px, height: 40px, padding: 4px and
margin: 10px, five different display values, in a 400 px row with words around them:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Five display values side by side</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.row { inline-size: 400px; margin-block-end: 16px; background: #eee; }
/* The same two spans in every row: a width, a height, a margin and padding on all four sides. */
.kid { inline-size: 120px; block-size: 40px; margin: 10px; padding: 4px; background: #dde; }
#block .kid { display: block; }
#inline .kid { display: inline; }
#inline-block .kid { display: inline-block; }
#flex .kid { display: flex; }
#grid .kid { display: grid; }
</style>
</head>
<body>
<div class="row" id="block">Text <span class="kid">one</span> and <span class="kid">two</span> end.</div>
<div class="row" id="inline">Text <span class="kid">one</span> and <span class="kid">two</span> end.</div>
<div class="row" id="inline-block">Text <span class="kid">one</span> and <span class="kid">two</span> end.</div>
<div class="row" id="flex">Text <span class="kid">one</span> and <span class="kid">two</span> end.</div>
<div class="row" id="grid">Text <span class="kid">one</span> and <span class="kid">two</span> end.</div>
<script>
const px = (n) => Math.round(n * 100) / 100; // engines measure in fractions of a pixel
for (const row of document.querySelectorAll('.row')) {
const box = row.getBoundingClientRect();
const [one, two] = [...row.querySelectorAll('.kid')].map((k) => k.getBoundingClientRect());
const where = one.top === two.top ? 'side by side' : 'one above the other';
console.log(`${row.id}: each span ${px(one.width)} x ${px(one.height)}, ${where}, ` +
`first at x ${px(one.left - box.left)}, row ${px(box.height)}px tall`);
}
</script>
</body>
</html> block: each span 128 × 48 px, on a line of its own, 10 px from the left; the row is 208 px tall.inline: each span 34.7 × 25 px, in the line after the word “Text”; the row stays 24 px tall.inline-block: each span 128 × 48 px, both in one line; the row is 92 px tall.flexandgrid: each span 128 × 48 px, on a line of its own; the row is 208 px tall.
The 128 px is the 120 px width plus 4 px of padding on each side. As inline, the span ignored its width and height:
it was as wide as the word “one” plus its padding, and the row stayed a single 24 px line, because its vertical padding
and margins changed nothing. The inline span’s height differed slightly, 25 px in Chromium, 27 px in Firefox and
25.88 px in WebKit, because each engine reads the font’s height in its own way; every other number agreed. From the
outside, flex and grid behaved exactly like block. The difference is inside, where each span now lays out its
word as a flex or grid item. That is the outer and the inner type at work.
Two keywords: the outer and the inner display type
The two keywords of a display value
Text description of the diagram
The diagram is a column of four boxes that takes the value display: inline flex apart. Its one-word form is inline-flex.
- The first keyword, inline, is the outer type. It decides how the box takes part in its parent's layout: the box sits in a line of text, beside words.
- The second keyword, flex, is the inner type. It decides how the box lays out its own children: they become flex items.
- The last box lists the shorthands. One keyword sets one type and the other takes its default: block means block flow, inline means inline flow, flex means block flex, and inline-block means inline flow-root.
CSS Display 3 splits every display value into an outer display type, how the box takes part in its parent’s flow
layout, and an inner display type, the kind of formatting context it creates for its children. The two-value
syntax writes both: display: block flow, display: inline flex, display: inline flow-root. It is Baseline widely
available. The one-word values you already know are short forms of these pairs, and a single keyword takes the default
for the half it leaves out: an outer keyword gets flow inside, and an inner keyword gets block outside.
// The one-keyword display values and the two-value form each one stands for (CSS Display 3, section 2).
// The first word is the outer display type: how the box takes part in its parent's layout.
// The second is the inner display type: how the box lays out its own children.
// getComputedStyle() reports the short form, so `display: inline flex` reads back as "inline-flex".
const pairs = {
block: ['block', 'flow'],
inline: ['inline', 'flow'],
'flow-root': ['block', 'flow-root'],
'inline-block': ['inline', 'flow-root'],
flex: ['block', 'flex'],
'inline-flex': ['inline', 'flex'],
grid: ['block', 'grid'],
'inline-grid': ['inline', 'grid'],
table: ['block', 'table'],
'inline-table': ['inline', 'table'],
};
const role = { block: 'starts a new line', inline: 'sits in a line of text' };
const children = {
flow: 'block and inline flow',
'flow-root': 'flow, in its own formatting context',
flex: 'flex items',
grid: 'grid items',
table: 'rows and cells',
};
for (const [short, [outer, inner]] of Object.entries(pairs)) {
console.log(`${short.padEnd(13)} = ${`${outer} ${inner}`.padEnd(17)} ${role[outer]}; children: ${children[inner]}`);
} Output
block = block flow starts a new line; children: block and inline flow inline = inline flow sits in a line of text; children: block and inline flow flow-root = block flow-root starts a new line; children: flow, in its own formatting context inline-block = inline flow-root sits in a line of text; children: flow, in its own formatting context flex = block flex starts a new line; children: flex items inline-flex = inline flex sits in a line of text; children: flex items grid = block grid starts a new line; children: grid items inline-grid = inline grid sits in a line of text; children: grid items table = block table starts a new line; children: rows and cells inline-table = inline table sits in a line of text; children: rows and cells
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node display_pairs.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
Browsers report a display value in its shortest backwards-compatible form, and the two keywords may come in either
order, so display: flex inline reads back from getComputedStyle() as inline-flex:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>What getComputedStyle reports for display</title>
<style>
/* Two-value forms, in either order. data-written repeats each value for the log. */
.a { display: inline flex; }
.b { display: flex inline; }
.c { display: block flow; }
.d { display: flow-root block; }
.e { display: inline flow-root; }
.f { display: block grid; }
.g { display: inline list-item; }
/* Boxes that get blockified: floated, absolutely positioned, or a flex item. */
.f1 { float: left; display: inline; }
.f2 { float: left; display: inline-block; }
.f3 { float: left; display: inline-flex; }
.f4 { position: absolute; display: inline-grid; }
.f5 { float: left; display: inline-table; }
.items { display: flex; }
.i1 { display: inline; }
.i2 { display: inline-block; }
.i3 { display: inline-flex; }
.i4 { display: table-cell; }
.i5 { display: contents; }
</style>
</head>
<body>
<div>
<span class="a" data-written="inline flex"></span>
<span class="b" data-written="flex inline"></span>
<span class="c" data-written="block flow"></span>
<span class="d" data-written="flow-root block"></span>
<span class="e" data-written="inline flow-root"></span>
<span class="f" data-written="block grid"></span>
<span class="g" data-written="inline list-item"></span>
</div>
<div>
<span class="f1" data-written="inline, floated"></span>
<span class="f2" data-written="inline-block, floated"></span>
<span class="f3" data-written="inline-flex, floated"></span>
<span class="f4" data-written="inline-grid, absolute"></span>
<span class="f5" data-written="inline-table, floated"></span>
</div>
<div class="items">
<span class="i1" data-written="inline, flex item"></span>
<span class="i2" data-written="inline-block, flex item"></span>
<span class="i3" data-written="inline-flex, flex item"></span>
<span class="i4" data-written="table-cell, flex item"></span>
<span class="i5" data-written="contents, flex item"></span>
</div>
<script>
for (const el of document.querySelectorAll('[data-written]')) {
console.log(`${el.dataset.written.padEnd(24)} -> ${getComputedStyle(el).display}`);
}
</script>
</body>
</html> All three engines agreed on every pair but one. WebKit 26.6 rejected inline list-item, so its span fell back to
inline, while Chromium and Firefox kept it as inline list-item: a feature’s Baseline status doesn’t promise that
every combination of it works. Most code only needs the one-word forms. The two-value form earns its place where it says more, such as
inline flow-root, a box that sits in a line but keeps its floats and margins inside, and it is the clearest way to
read what the one-word forms mean.
Floats, positioned boxes and flex items become blocks
Some layouts change the outer type for you. A float, an absolutely or fixed positioned box, a flex or grid item and
the root element are blockified: their outer type becomes block whatever you wrote. The same page measured it:
| Written | Result |
|---|---|
inline, floated |
block |
inline-block, floated |
block |
inline-flex, floated |
flex |
inline-grid, absolute |
grid |
inline-table, floated |
table |
inline, a flex item |
block |
inline-block, a flex item |
block |
table-cell, a flex item |
block |
contents, a flex item |
contents |
A blockified inline-block becomes a plain block, not flow-root. The specification keeps that rule for
compatibility with CSS 2, and nothing is lost, because floats, positioned boxes and flex and grid items all start their
own formatting context anyway. Blockification is also why display: inline on a flex item changes nothing, and why a floated span takes the
width you give it.
inline-block, and the gap under images
inline-block, in full inline flow-root, is the box for things that belong in a line of text but need a size: a
badge, a link styled as a button, an icon with padding. From the outside it is a word in the line. Inside, it is a block
container with its own formatting context, so width, height, padding and margins all apply, and its floats and its
children’s margins stay inside it. A “New” badge with width: 80px, height: 30px, padding: 4px 8px and
margin: 12px, in a 320 px paragraph:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A badge as inline and inline-block, and the gap under an image</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
p { inline-size: 320px; margin: 0 0 16px; }
.badge { inline-size: 80px; block-size: 30px; padding: 4px 8px; margin: 12px; background: #fd8; }
#as-inline-block .badge { display: inline-block; }
/* An image 80px tall in a box with no padding: any space below it is a gap. */
.frame { inline-size: 300px; margin-block-end: 8px; background: #eee; }
.frame img { inline-size: 120px; block-size: 80px; background: #99c; }
#middle img { vertical-align: middle; }
#bottom img { vertical-align: bottom; }
#block img { display: block; }
#line-height-normal { line-height: normal; }
#icon-middle img { inline-size: 16px; block-size: 16px; vertical-align: middle; }
</style>
</head>
<body>
<p id="as-inline">A badge <span class="badge">New</span> sits in this line of text, which wraps onto a second line.</p>
<p id="as-inline-block">A badge <span class="badge">New</span> sits in this line of text, which wraps onto a second line.</p>
<div class="frame" id="baseline"><img alt=""></div>
<div class="frame" id="line-height-normal"><img alt=""></div>
<div class="frame" id="middle"><img alt=""></div>
<div class="frame" id="bottom"><img alt=""></div>
<div class="frame" id="block"><img alt=""></div>
<div class="frame" id="icon-middle"><img alt=""></div>
<script>
const px = (n) => Math.round(n * 100) / 100;
for (const p of document.querySelectorAll('p')) {
const badge = p.querySelector('.badge').getBoundingClientRect();
console.log(`${p.id}: badge ${px(badge.width)} x ${px(badge.height)}, paragraph ${px(p.getBoundingClientRect().height)}px tall`);
}
for (const frame of document.querySelectorAll('.frame')) {
const gap = frame.getBoundingClientRect().bottom - frame.querySelector('img').getBoundingClientRect().bottom;
console.log(`${frame.id}: ${px(gap)}px below the image`);
}
</script>
</body>
</html> - As
inline, the badge measured 48 × 25 px, and the paragraph kept its two lines of 24 px, 48 px in all. - As
inline-block, it measured 96 × 38 px, and the paragraph grew to 86 px, because the first line made room for it.
An inline-block sits on the baseline of its last line of text by default, and the line grows to hold it and its
margins; vertical-align lines it up another way.
Images are inline by default, and that causes one of the most asked-about gaps in CSS: a few pixels of space under an image in a box with no padding. An inline image sits on the baseline, where the bottom of the letter x is, and every line box keeps room below the baseline for descenders, the tails of g, p and y. The program works out that room from Arial’s metrics:
// Why an inline image has a gap under it: it sits on the baseline, and the line box keeps room below the
// baseline for the descenders of letters such as g and p (CSS 2, section 10.8: the "strut").
// Arial's metrics, in font units of a 2,048-unit em: 1,854 above the baseline and 434 below it.
const ascent = 1854 / 2048;
const descent = 434 / 2048;
function gapBelowImage(fontSize, lineHeight) {
const content = (ascent + descent) * fontSize; // the height of the font's glyph area
const halfLeading = (lineHeight - content) / 2; // line-height adds half of the rest above and half below
return descent * fontSize + halfLeading; // what stays under the baseline
}
for (const [fontSize, lineHeight] of [[16, 24], [16, 32], [20, 30]]) {
const gap = gapBelowImage(fontSize, lineHeight);
console.log(`font-size ${fontSize}px, line-height ${lineHeight}px: ${gap.toFixed(2)}px under the image`);
}
console.log('vertical-align: bottom or display: block: 0px under the image (it no longer sits on the baseline)'); Output
font-size 16px, line-height 24px: 6.45px under the image font-size 16px, line-height 32px: 10.45px under the image font-size 20px, line-height 30px: 8.07px under the image vertical-align: bottom or display: block: 0px under the image (it no longer sits on the baseline)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node image_gap.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
The same page measured an 80 px image in a 24 px line, and a 16 px icon:
| The image has | Gap below it |
|---|---|
| the defaults | 6.45 px in WebKit, 6.5 px in Firefox, 7 px in Chromium |
a parent with line-height: normal |
3.45 to 4.27 px |
vertical-align: middle |
0 px |
vertical-align: bottom |
0 px |
display: block |
0 px |
vertical-align: middle, as a 16 px icon |
2.61 to 3.16 px |
WebKit’s 6.45 px is exactly the program’s answer; Firefox and Chromium round the font metrics before they add them up.
For an image that stands on its own, such as the photo of a card, display: block is the clean fix. For an icon that
belongs in a line of text, keep it inline and use vertical-align: bottom, or middle to centre it on the text.
middle removed the gap for the 80 px image only because the image is taller than the line: the 16 px icon still had
about 3 px under it.
Floats, and text that follows a shape
A float is taken out of normal flow and pushed to the left or right of its container, and the lines of text that
follow it get shorter, so they wrap around it. Pages are no longer laid out with floats; flexbox and grid do that job.
Floats remain the way to wrap text around something, such as a figure, a pull quote or a drop cap. Three rules cover
most of their behaviour. Block boxes next to a float ignore it, so their backgrounds run under it, while their lines
wrap around it. clear moves a box down below earlier floats. And a container doesn’t grow to hold its floats unless
it starts a block formatting context, which is what display: flow-root is for. float: inline-start and
float: inline-end are the logical forms of left and right, and they follow the direction of the text.
By default the lines wrap around the float’s margin box, a rectangle, even when the float looks round. shape-outside
gives the float a shape for the lines to follow instead, such as circle(), ellipse(), polygon() or the
transparent parts of an image, and shape-margin adds space around that shape. A round pull quote, 160 px across, in
a 340 px column, with the start of each line measured:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A floated pull quote, with and without shape-outside</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
article { inline-size: 340px; margin-block-end: 24px; }
.quote {
float: inline-start;
inline-size: 160px;
block-size: 160px;
margin-inline-end: 12px;
border-radius: 50%; /* the circle you see */
background: #cde;
}
#circle .quote {
shape-outside: circle(50%); /* the circle the lines follow, measured against the margin box */
shape-margin: 12px;
}
#circle-fixed .quote {
shape-outside: circle(50%) border-box; /* measured against the circle you see */
shape-margin: 12px;
margin-inline-end: 24px; /* a shape never reaches past the margin box, so leave room for it */
}
</style>
</head>
<body>
<article id="box"><div class="quote"></div><p>Floats take a box out of the line and push it to one side, and the lines of text that follow wrap around it. With shape-outside the lines follow a circle instead of the box, so each line starts a little further left or right than the one before it, as the curve of the circle passes.</p></article>
<article id="circle"><div class="quote"></div><p>Floats take a box out of the line and push it to one side, and the lines of text that follow wrap around it. With shape-outside the lines follow a circle instead of the box, so each line starts a little further left or right than the one before it, as the curve of the circle passes.</p></article>
<article id="circle-fixed"><div class="quote"></div><p>Floats take a box out of the line and push it to one side, and the lines of text that follow wrap around it. With shape-outside the lines follow a circle instead of the box, so each line starts a little further left or right than the one before it, as the curve of the circle passes.</p></article>
<script>
// Where each line of the paragraph starts, measured one character at a time.
for (const article of document.querySelectorAll('article')) {
const p = article.querySelector('p');
const text = p.firstChild;
const left = p.getBoundingClientRect().left;
const range = document.createRange();
const starts = new Map();
for (let i = 0; i < text.length; i++) {
range.setStart(text, i);
range.setEnd(text, i + 1);
const r = range.getClientRects()[0];
if (r && r.width > 0 && !starts.has(Math.round(r.top))) starts.set(Math.round(r.top), Math.round(r.left - left));
}
console.log(`${article.id}: lines start at ${[...starts.values()].slice(0, 8).join(', ')} px`);
}
</script>
</body>
</html> - No shape, with a 12 px margin: all seven lines start at 172 px.
circle(50%)withshape-margin: 12px: the lines start at 163, 172, 172, 172, 172, 172 and 156 px.circle(50%) border-boxwithshape-margin: 12pxand a 24 px margin: 153, 166, 172, 172, 171, 163 and 146 px.
Two rules explain the middle case. A shape is measured against the float’s margin box unless you name another box, so
circle(50%) was centred 6 px to the right of the circle you see. And a shape never reaches past the margin box, so a
12 px margin cut off the 12 px shape margin on five of the lines. Measuring against border-box and giving the float a
margin at least as wide as the shape margin lets the lines follow the circle all the way round, as the last case shows;
WebKit started some of those lines 1 px further left. shape-outside is Baseline widely available, and it changes
only how the text wraps: border-radius or clip-path changes how the float looks.
Hiding content: what each technique removes
Hiding something is easy; hiding it from the right people is the skill. display: none removes the element and
everything inside it from the layout, from the keyboard’s tab order and from the accessibility tree that screen readers
use. visibility: hidden makes the box invisible but keeps its space; it also takes the element out of the tab order
and the accessibility tree, and a descendant can set visibility: visible to show again. opacity: 0 only makes the
box transparent: it keeps its space, and everything in it stays focusable and is still read out.
content-visibility: hidden keeps the element’s own box but skips its contents, which can’t be focused, selected or
found with find-in-page; it is Baseline newly available. The HTML hidden attribute is display: none from the
browser’s own style sheet, and hidden="until-found" is content-visibility: hidden that find-in-page and links to
a fragment inside it can open.
The lab hides the same panel, a flex box with 8 px of padding and a link inside, eleven ways, between two markers:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Six ways to hide a panel, measured</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.marker { block-size: 10px; }
/* A component rule like many sites have: it sets display, so it beats the browser's [hidden] rule. */
.panel { display: flex; gap: 8px; padding: 8px; border: 1px solid; }
.none { display: none; }
.invisible { visibility: hidden; }
.invisible .shown-again { visibility: visible; }
.skipped { content-visibility: hidden; }
.transparent { opacity: 0; }
/* Two guard rules: the first also catches hidden="until-found"; the second leaves it alone. */
.guard-all [hidden] { display: none !important; }
.guard-safe [hidden]:not([hidden="until-found" i]) { display: none !important; }
</style>
</head>
<body>
<div class="case" id="display-none"><div class="marker"></div><div class="panel none"><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case" id="visibility-hidden"><div class="marker"></div><div class="panel invisible"><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case" id="visibility-child-visible"><div class="marker"></div><div class="panel invisible"><a href="#a">A link</a><a class="shown-again" href="#b">Back</a></div><div class="marker"></div></div>
<div class="case" id="content-visibility-hidden"><div class="marker"></div><div class="panel skipped"><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case" id="opacity-0"><div class="marker"></div><div class="panel transparent"><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case" id="hidden-plain-div"><div class="marker"></div><div hidden><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case" id="hidden-on-panel"><div class="marker"></div><div class="panel" hidden><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case guard-all" id="hidden-guard-all"><div class="marker"></div><div class="panel" hidden><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case" id="until-found"><div class="marker"></div><div class="panel" hidden="until-found"><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case guard-all" id="until-found-guard-all"><div class="marker"></div><div class="panel" hidden="until-found"><a href="#a">A link</a></div><div class="marker"></div></div>
<div class="case guard-safe" id="until-found-guard-safe"><div class="marker"></div><div class="panel" hidden="until-found"><a href="#a">A link</a></div><div class="marker"></div></div>
<script>
const px = (n) => Math.round(n * 100) / 100;
for (const c of document.querySelectorAll('.case')) {
const [above, panel, below] = c.children;
const link = panel.querySelector('a');
const space = px(below.getBoundingClientRect().top - above.getBoundingClientRect().bottom);
link.focus();
const focusable = document.activeElement === link;
link.blur();
let line = `${c.id}: panel boxes ${panel.getClientRects().length}, space ${space}px, link boxes ${link.getClientRects().length}, ` +
`link focusable ${focusable}`;
const back = panel.querySelector('.shown-again');
if (back) {
back.focus();
line += `, "Back" focusable ${document.activeElement === back}`;
back.blur();
}
console.log(line);
}
</script>
</body>
</html> display: none: no box, 0 px of space; the link can’t take focus.visibility: hidden: one box, 42 px of space; the link can’t take focus. A child set tovisibleshows again and takes focus.content-visibility: hidden: one box, 18 px of space; the link can’t take focus.opacity: 0: one box, 42 px of space, and the link still takes focus.hiddenon a plaindiv: no box, 0 px of space; no focus.hiddenon.panel { display: flex }: one box, 42 px of space, and the panel shows: its link takes focus.- The same with
[hidden] { display: none !important }: no box, 0 px of space; no focus. hidden="until-found": one box, 18 px of space; no focus.until-foundwith the same guard: no box, 0 px of space; no focus.until-foundwith the guard below: one box, 18 px of space; no focus.
The 42 px is the panel’s 24 px line plus 16 px of padding and 2 px of border. The 18 px that
content-visibility: hidden leaves is the padding and border alone, because skipping the contents also sizes the box
as if it were empty. All three engines gave these numbers, with one difference: Chromium reported no boxes for the link inside a skipped
panel, while Firefox and WebKit reported one. So don’t count boxes to find out whether something is visible; the
element.checkVisibility() method, Baseline widely available, exists for that question.
Two of those results are bugs: “the link still takes focus” and “the panel shows”. opacity: 0 hides nothing from
a keyboard user, who can tab into a link they can’t see. And the hidden attribute loses to any site rule that sets display, because the browser’s [hidden] rule is an
ordinary declaration in the browser’s own style sheet, and every normal declaration of the site beats it whatever the
selectors. The HTML Standard warns about exactly this. The fix is a guard rule, written so that it leaves
until-found alone: a plain [hidden] selector also matches hidden="until-found", and in the lab it turned those
panels into display: none, which find-in-page can’t open.
/* The hidden attribute should beat component rules such as .panel { display: flex }.
!important beats every normal rule, and in the first layer it also beats the !important rules of later layers
and of unlayered CSS. The :not() part leaves hidden="until-found" alone, because that state hides with
content-visibility and must keep its box. */
@layer base, components, utilities;
@layer base {
[hidden]:not([hidden="until-found" i]) {
display: none !important;
}
} hidden="until-found" itself has limited availability: Chromium and Firefox support it, Safari doesn’t yet. WebKit
26.6 hid the panel like the others, so give the content a second way in, such as the button of an accordion.
display: contents removes only the element’s own box
display: contents removes the element’s own box and keeps its children’s, as if the children stood in its place. A
wrapper div with display: contents lets its children become flex or grid items of the wrapper’s parent:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>What display: contents removes</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.row { display: flex; gap: 8px; }
.row span { padding: 4px 8px; background: #dde; }
.wrapper { display: contents; border: 2px solid; } /* the border has no box to be drawn on */
.unboxed { display: contents; }
</style>
</head>
<body>
<div class="row"><div class="wrapper" id="wrapper"><span>One</span><span>Two</span></div><span>Three</span></div>
<p><button type="button" class="unboxed" id="save">Save</button> <button type="button" id="cancel">Cancel</button></p>
<script>
const wrapper = document.getElementById('wrapper');
const tops = [...document.querySelectorAll('.row span')].map((s) => Math.round(s.getBoundingClientRect().top));
console.log(`wrapper: ${wrapper.getClientRects().length} boxes; its two spans and the third sit in one flex row: ${new Set(tops).size === 1}`);
for (const id of ['save', 'cancel']) {
const button = document.getElementById(id);
button.focus();
console.log(`${id} button: ${button.getClientRects().length} boxes, focus() focused it: ${document.activeElement === button}`);
button.blur();
}
</script>
</body>
</html> The wrapper had no box, and its two spans lined up in the flex row with the third. The button with display: contents
had no box either, and focus() could not focus it in any of the three engines, although the HTML Standard says such
an element stays focusable; the button next to it focused normally. Browsers have also shipped bugs that dropped
elements with display: contents from the accessibility tree, so a button, heading or list could stop being one for a
screen reader, and web-features lists display: contents as limited availability. Use it only on wrappers that carry
no meaning, and never on interactive elements.
Practise: computed display values and boxes
The exercise turns the measurements of this lesson into two functions, and its tests are the cases of the labs.
Exercise · Medium · JavaScript
Predict computed display values and which panels keep a box
Two questions come up whenever a layout surprises you: which display value did the browser really use, and does the element have a box at all? Write two functions in display.mjs and export them.
The first, computedDisplay(value, { blockified = false } = {}), takes a display value as written in CSS and returns what getComputedStyle() reports for it, or null when the browser would reject the value:
- One keyword stays as it is:
block,inline,flow-root,inline-block,flex,inline-flex,grid,inline-grid,table,inline-table,list-item,none,contents, and the table parts such astable-cell. flowalone meansblock flow, so it reads back asblock.- Two keywords, in either order, are one outer type (
blockorinline) and one inner type (flow,flow-root,flex,gridortable). Return the short form:block flowisblock,block flow-rootisflow-root,inline flowisinline,inline flow-rootisinline-block,block flexisflex,inline flexisinline-flex, and the same pattern forgridandtable. Any other pair, such as two outer types orinline contents, is invalid. - With
blockified: true(the box floats, is absolutely positioned or is a flex or grid item), the outer type becomesblock. Aninline flow-rootbox becomes a plainblock, a table part such astable-cellbecomesblock, andnone,contentsandlist-itemstay as they are.
The second, boxCount({ display = null, hidden = false, guard = 'none' } = {}), says how many boxes getClientRects() returns for a div panel: 0 or 1.
displayis the value the site's CSS sets on the panel, ornullwhen it sets none (adivis then a block).hiddenis the panel's hidden attribute:false,trueor'until-found'. The browser's own rule giveshiddenpanelsdisplay: none, but anydisplaythe site sets beats it.'until-found'hides withcontent-visibility: hidden, which keeps the box.guardis the site's guard rule:'none','all'for[hidden] { display: none !important; }, or'not-until-found'for the same rule on[hidden]:not([hidden="until-found" i]). A guard wins over every other display rule for the panels it matches.display: noneanddisplay: contentsleave no box of the panel's own.
The sample tests import both functions from display.mjs and run in your browser.
Starter code · display.mjs
/** What getComputedStyle() reports for a display value, or null when the browser rejects it. */
export function computedDisplay(value, { blockified = false } = {}) {
// Replace this line with your code.
return value;
}
/** How many boxes getClientRects() returns for a div panel: 0 or 1. */
export function boxCount({ display = null, hidden = false, guard = 'none' } = {}) {
// Replace this line with your code.
return 1;
} The sample tests · display.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { computedDisplay, boxCount } from './display.mjs';
test('one keyword reads back as written', () => {
for (const v of ['block', 'inline', 'flow-root', 'inline-block', 'flex', 'inline-flex', 'grid', 'table', 'list-item', 'none', 'contents']) {
assert.equal(computedDisplay(v), v);
}
});
test('two keywords read back in the short form, in either order', () => {
assert.equal(computedDisplay('inline flex'), 'inline-flex');
assert.equal(computedDisplay('flex inline'), 'inline-flex');
assert.equal(computedDisplay('block flow'), 'block');
assert.equal(computedDisplay('flow-root block'), 'flow-root');
assert.equal(computedDisplay('inline flow-root'), 'inline-block');
assert.equal(computedDisplay('block grid'), 'grid');
assert.equal(computedDisplay('inline table'), 'inline-table');
});
test('a keyword left out takes its default', () => {
assert.equal(computedDisplay('flow'), 'block');
assert.equal(computedDisplay('inline flow'), 'inline');
});
test('values the browser rejects', () => {
assert.equal(computedDisplay('inline block'), null);
assert.equal(computedDisplay('flex grid'), null);
assert.equal(computedDisplay('flex-inline'), null);
assert.equal(computedDisplay('inline contents'), null);
});
test('floats, absolutely positioned boxes and flex items are blockified', () => {
const b = { blockified: true };
assert.equal(computedDisplay('inline', b), 'block');
assert.equal(computedDisplay('inline-block', b), 'block');
assert.equal(computedDisplay('inline-flex', b), 'flex');
assert.equal(computedDisplay('inline grid', b), 'grid');
assert.equal(computedDisplay('inline-table', b), 'table');
assert.equal(computedDisplay('table-cell', b), 'block');
assert.equal(computedDisplay('contents', b), 'contents');
assert.equal(computedDisplay('flex', b), 'flex');
});
test('the hidden attribute against the site\'s display rules', () => {
assert.equal(boxCount({}), 1);
assert.equal(boxCount({ hidden: true }), 0);
assert.equal(boxCount({ display: 'flex', hidden: true }), 1);
assert.equal(boxCount({ display: 'flex', hidden: true, guard: 'all' }), 0);
assert.equal(boxCount({ display: 'none' }), 0);
assert.equal(boxCount({ display: 'contents' }), 0);
});
test('hidden="until-found" keeps its box unless a guard sets display: none', () => {
assert.equal(boxCount({ display: 'flex', hidden: 'until-found' }), 1);
assert.equal(boxCount({ display: 'flex', hidden: 'until-found', guard: 'all' }), 0);
assert.equal(boxCount({ display: 'flex', hidden: 'until-found', guard: 'not-until-found' }), 1);
}); A hint
Split the value on spaces. For one word, look it up: the legacy words map to a pair (inline-flex is inline flex), an outer word gets flow and an inner word gets block. For two words, find exactly one outer and one inner word. Then, if blockified, swap the outer word for block (watch inline flow-root), and turn the pair back into its short form with a lookup table keyed by "outer inner".
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.
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.
Interview questions
Fresher · What is the difference between display: none, visibility: hidden and opacity: 0?
display: none removes the element and its descendants from the layout, so the space closes up, and from the tab
order and the accessibility tree. visibility: hidden keeps the box and its space but draws nothing; the element can’t
take focus and screen readers skip it, while a descendant with visibility: visible still shows. opacity: 0 makes the
element fully transparent and nothing else: it keeps its space, stays focusable and is still read out, so it is not
a way to hide content from anyone. In a measured case, a panel hidden with visibility: hidden or
opacity: 0 kept all 42 px of its space and display: none left 0 px, but only with opacity: 0 could its link still
take focus. Use display: none or the hidden attribute to hide content from everyone.
Key takeaways
- Block-level boxes stack and fill the line; inline-level boxes flow in lines, and on a
spanwidth, height and vertical margins do nothing. displaysets an outer type, how the box sits among its siblings, and an inner type, how it lays out its children:inline-flexisinline flex,inline-blockisinline flow-root.- Floats, absolutely positioned boxes and flex and grid items are blockified, and a blockified
inline-blockbecomesblock. inline-blockgives an element in a line a size; an inline image leaves a gap for descenders, whichdisplay: blockorvertical-align: bottomremoves.shape-outsidemakes text wrap along a shape; measure it againstborder-boxand leave a margin as wide as the shape margin.display: noneandhiddenremove the box;visibility: hiddenkeeps the space;opacity: 0hides nothing from the keyboard. Guardhiddenwith[hidden]:not([hidden="until-found" i]) { display: none !important; }in the first layer, and keepdisplay: contentsoff interactive elements.
References
- CSS Display Module Level 3: the display property (W3C CSS Working Group)
- CSS Display Module Level 3: box generation, the none and contents keywords (W3C CSS Working Group)
- CSS Display Module Level 3: automatic box type transformations (W3C CSS Working Group)
- CSS 2: normal flow, block and inline formatting contexts (W3C CSS Working Group)
- CSS 2: floats (W3C CSS Working Group)
- CSS 2: line height calculations, the strut and vertical-align (W3C CSS Working Group)
- CSS Shapes Module Level 1: the shape-outside property (W3C CSS Working Group)
- CSS Containment Module Level 2: the content-visibility property (W3C CSS Working Group)
- HTML Standard: the hidden attribute (WHATWG)
- HTML Standard: rendering, hidden elements (WHATWG)
- display (MDN Web Docs) (Mozilla)
- visibility (MDN Web Docs) (Mozilla)
- Two-value display property (web-features) (W3C WebDX Community Group)
- display: contents (web-features) (W3C WebDX Community Group)
- hidden="until-found" (web-features) (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress