CSS Module 9 – Normal flow, positioning and stacking
Positioning: relative, absolute, fixed and sticky
How relative, absolute, fixed and sticky positioning work, how to find the containing block, the transform trap, and sticky headers that stay accessible.
What you will learn
- Use relative, absolute, fixed and sticky positioning with the inset properties
- Find an element's containing block, including the transform and filter traps
- Use inset and auto margins to centre a positioned box
- Build sticky headers and sticky table headers that actually stick
- Prevent a sticky header from hiding the element that has keyboard focus
Before you start
What you’ll learn · Before you start
What you will learn
- Use relative, absolute, fixed and sticky positioning with the inset properties
- Find an element's containing block, including the transform and filter traps
- Use inset and auto margins to centre a positioned box
- Build sticky headers and sticky table headers that actually stick
- Prevent a sticky header from hiding the element that has keyboard focus
Before you start
On this page
The position property decides how a box is placed. static, the default, leaves it in normal flow.
relative keeps its place in the flow and shifts it on screen by the inset properties, top, right, bottom
and left, leaving its old space empty. absolute takes the box out of the flow and places it against its
containing block, the padding box of its nearest positioned ancestor. fixed does the same against the
viewport, so the box stays put while the page scrolls. sticky keeps the box in the flow until scrolling would
carry it past an inset such as top: 0, then holds it there for as long as its parent is on screen. Three measured
surprises follow: an ancestor with a transform or a filter captures fixed boxes, a sticky box needs room inside its
parent, and a sticky header can hide the element that has keyboard focus. Every number in this lesson was measured in
Chromium 153, Firefox 155 and WebKit 26.6.
The five values at a glance
static: in normal flow, where the insets do nothing; it scrolls with the page.relative: in normal flow, and its space is kept; shifted from its own place; it scrolls with the page.absolute: out of the flow, taking no space; placed against its containing block, and scrolls with it.fixed: out of the flow, taking no space; placed against the viewport, so it doesn’t move when the page scrolls.sticky: in normal flow, and its space is kept; held at its inset inside the scrollport until its parent ends.
The inset properties set the offsets: top, right, bottom and left, their logical forms such as
inset-block-start and inset-inline-end, and the inset shorthand, which sets all four at once, so inset: 0 means
0 on every side; it is Baseline widely available. Any position other than static also lets the box take a
z-index, and fixed and sticky boxes always start a stacking context, which matters as soon as boxes overlap.
relative and absolute positioning
relative is the gentle one. The lab moved a 40 px box with top: 20px and left: 30px, written as
inset-block-start and inset-inline-start: it was drawn 20 px lower and 30 px further right, over its neighbour,
and the box after it didn’t move at all, still starting 40 px below the box before it. The offset is only visual, and
the space stays where the box was. Most position: relative in real code has no offsets: it is there because any
position other than static makes the box a containing block for its absolutely positioned descendants.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>relative and absolute positioning, measured</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.box { inline-size: 100px; block-size: 40px; background: #dde; }
/* relative: moved 20px down and 30px right; the space it had stays where it was */
#moved { position: relative; inset-block-start: 20px; inset-inline-start: 30px; }
/* absolute, against the nearest positioned ancestor: a badge in the corner of a card */
.card { position: relative; inline-size: 300px; block-size: 120px; margin-block: 40px; padding: 10px; border: 5px solid; }
.badge { position: absolute; inset-block-start: 8px; inset-inline-end: 8px; padding: 2px 6px; background: #fd8; }
.static-spot { position: absolute; background: #cfc; } /* no insets: it stays where it would have been */
/* centring: inset 0 and auto margins share the free space, once the box has a size */
.stage { position: relative; inline-size: 400px; block-size: 300px; border: 1px solid; }
.centred { position: absolute; inset: 0; margin: auto; inline-size: 200px; block-size: 100px; background: #cde; }
.stretched { position: absolute; inset: 0; margin: auto; background: #edc; } /* no size: it fills the stage */
</style>
</head>
<body>
<div class="box" id="before">Before</div>
<div class="box" id="moved">Moved</div>
<div class="box" id="after">After</div>
<div class="card" id="card"><span class="badge" id="badge">New</span>Card text <span class="static-spot" id="spot">here</span></div>
<div class="stage" id="stage1"><div class="centred" id="centred"></div></div>
<div class="stage" id="stage2"><div class="stretched" id="stretched"></div></div>
<script>
const r = (id) => document.getElementById(id).getBoundingClientRect();
const px = (n) => Math.round(n * 100) / 100;
console.log(`relative: "Moved" is ${px(r('moved').top - r('before').bottom)}px below "Before" and ${px(r('moved').left)}px from the left; ` +
`"After" starts ${px(r('after').top - r('before').bottom)}px below "Before"`);
const card = r('card');
console.log(`badge: ${px(r('badge').top - card.top)}px from the card's top edge, ${px(card.right - r('badge').right)}px from its right edge`);
console.log(`no insets: the span starts ${px(r('spot').left - card.left)}px from the card's left edge, beside the text`);
for (const id of ['centred', 'stretched']) {
const box = r(id); const stage = r(id === 'centred' ? 'stage1' : 'stage2');
console.log(`${id}: ${px(box.width)} x ${px(box.height)}, ${px(box.left - stage.left - 1)}px from the left and ${px(box.top - stage.top - 1)}px from the top of the stage's padding box`);
}
</script>
</body>
</html> An absolutely positioned box is taken out of the flow: it takes no space, and the boxes around it are laid out as
if it weren’t there. Its insets are measured from its containing block, which is the padding box, the area just
inside the border, of the nearest ancestor whose position isn’t static. With no such ancestor it is the initial
containing block, a rectangle the size of the viewport at the top of the page. The lab’s “New” badge, with top: 8px
and right: 8px, sat 13 px from the outer edges of its card: 8 px plus the card’s 5 px border. With no insets at all,
an absolute box stays at its static position, the place it would have had in the flow: the lab’s span stayed beside
the card’s text, 79.9 px from the edge, but took no space there.
Centring with inset and auto margins
inset: 0 makes the area the box may use the whole containing block. When the box has a size, margin: auto then
shares out what is left, so position: absolute; inset: 0; margin: auto with width: 200px and height: 100px
centred the box in a 400 × 300 px stage, with 100 px of space on every side. Without a size, the same declarations
stretched the box to fill the stage, 400 × 300 px. Fixed boxes follow the same rules against the viewport, which is
how a dialog box is centred on screen. The older top: 50%; left: 50%; transform: translate(-50%, -50%) centres a box
of unknown size too, but its transform makes the box the containing block of any fixed descendants, the trap of the
next section.
fixed, and the ancestors that capture it
A fixed box is placed against the viewport and stays where it is while the page scrolls, which makes it the usual
choice for a back-to-top button or a chat button. It has one trap that catches most teams once. When an ancestor has a
transform, a filter or one of a handful of related properties, that ancestor becomes the containing block of its
fixed descendants, and “fixed” now means fixed to the ancestor: the box scrolls away with it. The value doesn’t have to
change anything you can see. Entrance animations are the usual source, because with animation-fill-mode: both the
last frame, transform: translateY(0), stays on the element after the animation ends:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A fixed back-to-top button inside an animated page wrapper</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.page { block-size: 3000px; }
.to-top { position: fixed; inset-block-end: 16px; inset-inline-end: 16px; padding: 8px 12px; }
/* An entrance animation. fill-mode both keeps its last frame, transform: translateY(0), after it ends. */
@keyframes rise {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
#kept .page { animation: rise 120ms ease-out both; }
/* backwards applies the first frame before the animation starts, and nothing after it ends */
#dropped .page { animation: rise 120ms ease-out backwards; }
</style>
</head>
<body>
<!-- The script below loads each case in turn into this page: one page wrapper, one button. -->
<main id="case"></main>
<script>
const viewport = () => document.documentElement.clientHeight;
const run = (id) => new Promise((done) => {
const holder = document.getElementById('case');
holder.innerHTML = `<div id="${id}"><div class="page"><a class="to-top" href="#case">Back to top</a></div></div>`;
const page = holder.querySelector('.page');
const settle = () => requestAnimationFrame(() => requestAnimationFrame(() => {
const button = holder.querySelector('.to-top');
window.scrollTo(0, 0);
const before = viewport() - button.getBoundingClientRect().bottom;
window.scrollTo(0, 800);
const after = viewport() - button.getBoundingClientRect().bottom;
console.log(`${id}: transform ${getComputedStyle(page).transform}; button ${before}px above the bottom of the viewport, ${after}px after scrolling 800px`);
window.scrollTo(0, 0);
done();
}));
if (getComputedStyle(page).animationName === 'none') settle();
else page.addEventListener('animationend', settle, { once: true });
});
(async () => {
await run('none');
await run('kept');
await run('dropped');
})();
</script>
</body>
</html> - No animation: the wrapper’s computed transform is
none, and the button sat 16 px above the bottom of the viewport, before and after 800 px of scrolling. - Fill mode
both: the transform stayed atmatrix(1, 0, 0, 1, 0, 0), and the button was at −2,384 px, then at −1,584 px after scrolling. - Fill mode
backwards: the transform went back tonone, and the button stayed 16 px above the bottom.
With the transform kept, the button was fixed to the bottom of the 3,000 px wrapper, far below the screen, and moved
with the page. Three fixes work. Let the animation drop its last frame with backwards, or animate a child of the
wrapper instead. Render fixed elements outside any transformed wrapper, for example as the last child of body. Or
put the element in the top layer as a popover or a modal dialog: the top layer can’t be clipped by ancestors, and a
fixed element in it always uses the viewport.
Which ancestors capture? The lab gives an ancestor one style at a time and checks where an absolute and a fixed child
with top: 0; left: 0 end up:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Which ancestor styles capture absolute and fixed boxes</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; block-size: 4000px; }
/* Each ancestor sits 40px down and 100px in, with a 5px border; its padding box starts 5px inside that. */
.ancestor { margin: 40px 0 0 100px; inline-size: 300px; block-size: 60px; border: 5px solid; }
.abs, .fix { inline-size: 20px; block-size: 20px; inset-block-start: 0; inset-inline-start: 0; }
.abs { position: absolute; }
.fix { position: fixed; }
</style>
</head>
<body>
<script>
// The style each ancestor gets. Every value is one that looks harmless: an identity transform, a zero blur.
const styles = [
'position: relative', 'transform: translateX(0)', 'translate: 0', 'filter: blur(0)', 'backdrop-filter: blur(0)',
'perspective: 500px', 'contain: paint', 'contain: layout', 'will-change: transform', 'content-visibility: auto',
'transform: none', 'contain: size', 'will-change: opacity',
'opacity: 0.99', 'isolation: isolate', 'clip-path: inset(0)', 'overflow: hidden', 'container-type: inline-size',
'z-index: 1',
];
const rows = styles.map((style) => {
const ancestor = document.createElement('div');
ancestor.className = 'ancestor';
ancestor.setAttribute('style', style);
ancestor.innerHTML = '<div class="abs"></div><div class="fix"></div>';
document.body.append(ancestor);
return { style, ancestor };
});
window.scrollTo(0, 37); // so that the page origin and the viewport are 37px apart
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const { style, ancestor } of rows) {
const a = ancestor.getBoundingClientRect();
const atAncestor = (el) => {
const r = el.getBoundingClientRect();
return Math.abs(r.left - (a.left + 5)) < 0.5 && Math.abs(r.top - (a.top + 5)) < 0.5;
};
const abs = atAncestor(ancestor.querySelector('.abs')) ? 'the ancestor' : 'the initial containing block';
const fix = atAncestor(ancestor.querySelector('.fix')) ? 'the ancestor' : 'the viewport';
console.log(`${style.padEnd(28)} absolute -> ${abs}; fixed -> ${fix}`);
}
}));
</script>
</body>
</html> - Capture both absolute and fixed children:
transform: translateX(0),translate: 0,filter: blur(0),backdrop-filter: blur(0),perspective: 500px,contain: paint,contain: layout,will-change: transformandcontent-visibility: auto. - Capture absolute children only:
position: relative. A fixed child still used the viewport. - Capture neither:
transform: none,contain: size,will-change: opacity,opacity: 0.99,isolation: isolate,clip-path: inset(0),overflow: hidden,container-type: inline-sizeandz-index: 1. Their children used the initial containing block and the viewport.
All three engines gave the same answer for every style. Identity values count: a transform that moves nothing and a blur of zero still capture. Most of the look-alikes in the last group do something else, starting a stacking context, clipping or making a size container, but none of them captures positioned boxes, so if a fixed box misbehaves, search its ancestors for the properties of the first group:
How the containing block of an absolute or fixed box is found
Text description of the diagram
The diagram is a column of steps for a box with position: absolute or fixed. The browser checks the box's ancestors one by one, nearest first.
- Does the ancestor have transform, translate, rotate, scale, perspective, filter, backdrop-filter, contain: layout or paint, content-visibility: auto, or will-change naming one of these? If so, it is the containing block, for absolute and fixed boxes alike.
- If not, and the box is absolutely positioned, is the ancestor's position anything other than static? If so, it is the containing block.
- If neither applies, the browser moves up to the next ancestor and asks again.
- When no ancestor matched, an absolute box uses the initial containing block, and a fixed box uses the viewport.
The last box notes that the containing block is the ancestor's padding box, the area just inside its border.
sticky: in the flow until it reaches its inset
position: sticky combines the other behaviours. The box is laid out in normal flow and keeps its space, and when
scrolling would carry it past an inset you set, such as top: 64px, the browser shifts it just enough to keep it
there. It sticks relative to its nearest scrollport, the viewport or the nearest ancestor that is a scroll
container, and it never leaves its containing block, the content box of its parent: when the end of the parent
arrives, the box is pushed out with it. Sticky positioning is Baseline widely available.
The lab has a 64 px sticky site header, a sticky box with no inset, and two sections 600 px tall whose 40 px headings
have top: 64px. It scrolls the page and records the top of each box, in pixels from the top of the viewport:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sticky headers, headings, a sidebar and a table, measured while scrolling</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.site-header { position: sticky; inset-block-start: 0; block-size: 64px; background: #dde; }
.no-inset { position: sticky; block-size: 30px; background: #ccc; } /* sticky, but no inset */
section { block-size: 600px; border-block-end: 1px solid; }
section h2 { position: sticky; inset-block-start: 64px; margin: 0; block-size: 40px; background: #eed; }
.layout { display: flex; gap: 16px; }
.layout .content { flex: 1; block-size: 1200px; }
.layout .sidebar { position: sticky; inset-block-start: 64px; inline-size: 200px; background: #cde; }
#start .sidebar { align-self: start; } /* without it the sidebar stretches to the row's full height */
.table-wrap { overflow: auto; max-block-size: 200px; inline-size: 300px; }
table { border-collapse: collapse; }
th, td { padding: 0 8px; block-size: 32px; white-space: nowrap; background: #fff; }
thead th { position: sticky; inset-block-start: 0; }
tbody th { position: sticky; inset-inline-start: 0; }
thead th:first-child { position: sticky; inset-inline-start: 0; z-index: 1; } /* the corner sits above both */
</style>
</head>
<body>
<header class="site-header" id="header">Site header</header>
<div class="no-inset" id="no-inset">Sticky without an inset</div>
<section id="one"><h2>Section one</h2></section>
<section id="two"><h2>Section two</h2></section>
<div class="layout" id="stretch"><div class="content"></div><aside class="sidebar">Stretched sidebar</aside></div>
<div class="layout" id="start"><div class="content"></div><aside class="sidebar">Sidebar, align-self: start</aside></div>
<div class="table-wrap" id="wrap"></div>
<div class="spacer"></div>
<script>
const px = (n) => Math.round(n * 100) / 100;
const topOf = (el) => px(el.getBoundingClientRect().top);
const [h1, h2] = document.querySelectorAll('section h2');
for (const y of [0, 100, 620, 700, 1200]) {
window.scrollTo(0, y);
console.log(`scrolled ${y}px: header ${topOf(document.getElementById('header'))}, no inset ${topOf(document.getElementById('no-inset'))}, ` +
`heading one ${topOf(h1)}, heading two ${topOf(h2)}`);
}
for (const id of ['stretch', 'start']) {
const layout = document.getElementById(id);
window.scrollTo(0, layout.offsetTop + 500);
console.log(`${id}: page 500px into the layout, sidebar top ${topOf(layout.querySelector('.sidebar'))}`);
}
// A table of 12 rows and 5 columns in a 300 x 200 scroll box.
const rows = Array.from({ length: 12 }, (_, i) => `<tr><th>Item ${i + 1}</th><td>${(i + 1) * 125}</td><td>${(i + 3) * 7}</td><td>A longer note</td><td>More</td></tr>`).join('');
const wrap = document.getElementById('wrap');
wrap.innerHTML = `<table><thead><tr><th>Item</th><th>Price</th><th>Qty</th><th>Notes</th><th>Extra</th></tr></thead><tbody>${rows}</tbody></table>`;
wrap.scrollIntoView();
wrap.scrollTo(400, 150);
const w = wrap.getBoundingClientRect();
const head = wrap.querySelector('thead th:nth-child(2)').getBoundingClientRect();
const first = wrap.querySelector('tbody tr:nth-child(8) th').getBoundingClientRect();
console.log(`table, scrolled ${wrap.scrollTop}px down and ${wrap.scrollLeft}px across: header cell ${px(head.top - w.top)}px from the box's top, ` +
`first-column cell ${px(first.left - w.left)}px from its left`);
</script>
</body>
</html> | Scrolled | Heading one | Heading two |
|---|---|---|
| 0 px | 94 | 695 |
| 100 px | 64 | 595 |
| 620 px | 34 | 75 |
| 700 px | −46 | 64 |
| 1,200 px | −546 | 55 |
All three engines agreed on every number. The header stayed at 0 throughout. The box with no inset never stuck: it
went from 64 to −36 after 100 px of scrolling and to −1,136 at 1,200 px, because position: sticky needs at least
one inset that isn’t auto. Heading one stuck under the header from 100 px; at 620 px the end of its section had pushed
it up to 34 px, and at 700 px it was gone, with heading two in its place. The whole behaviour is one clamp, and the
program reproduces the table:
// position: sticky as arithmetic (CSS Position 3, section 3.4). A sticky box is pushed down just enough to stay
// `inset` pixels below the top of its scrollport, but never so far that it leaves its containing block, the content
// box of its parent. The numbers are the sticky lab's: a 64px site header, then sections 600px tall with 40px
// headings that have top: 64px.
function stickyTop({ normalTop, height, inset, blockEnd, scrolled }) {
const inFlow = normalTop - scrolled; // where normal flow would put it, from the top of the viewport
const stuck = Math.max(inFlow, inset); // pushed down to stay `inset` below the top
const lowest = blockEnd - scrolled - height; // the last place where it still fits inside its section
return Math.min(stuck, lowest);
}
const headings = [
{ name: 'heading one', normalTop: 94, height: 40, inset: 64, blockEnd: 694 },
{ name: 'heading two', normalTop: 695, height: 40, inset: 64, blockEnd: 1295 },
];
for (const scrolled of [0, 100, 620, 700, 1200]) {
const tops = headings.map((h) => `${h.name} ${stickyTop({ ...h, scrolled })}`);
console.log(`scrolled ${String(scrolled).padStart(4)}px: ${tops.join(', ')}`);
} Output
scrolled 0px: heading one 94, heading two 695 scrolled 100px: heading one 64, heading two 595 scrolled 620px: heading one 34, heading two 75 scrolled 700px: heading one -46, heading two 64 scrolled 1200px: heading one -546, heading two 55
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node sticky_offset.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
Two things stop a sticky box from moving. An ancestor that becomes a scroll container, for example with
overflow: hidden, becomes the scrollport it sticks to, as the overflow lesson
measured. And a box with no room inside its containing block has nowhere to go. A sidebar in a flex row is stretched to
the full height of the row by default, so in the lab it scrolled away with the page, to −500 px after 500 px of
scrolling, in all three engines; with align-self: start it kept its own height and stayed at 64 px.
Sticky table headers and first columns
For a data table, make the cells sticky, not the table: thead th with top: 0 keeps the header row in view, and
tbody th with left: 0, or inset-inline-start: 0, keeps the first column. Put the table in a box with
overflow: auto and a maximum height, so that the box is the scrollport for both edges. The lab scrolled such a box,
300 by 200 px, 150 px down and as far across as it went, 35 px (36 px in Firefox): the header cell stayed 0 px from
the top of the box and the first-column cell 0 px from its left, in all three engines. A sticky thead or tr with
top: 0 also kept the header row in view in all three, but only sticky cells can stick on both edges.
/* A table that keeps its header row and first column in view inside its own scroll box. */
.table-wrap {
overflow: auto; /* the scroll box both sticky edges stick to */
max-block-size: 70vh;
}
thead th {
position: sticky;
inset-block-start: 0; /* the header row sticks to the top of the box */
}
tbody th {
position: sticky;
inset-inline-start: 0; /* the first column sticks to the start edge */
}
thead th:first-child {
inset-inline-start: 0; /* the corner cell sticks both ways */
z-index: 1; /* and stays above the cells that scroll under it */
}
th, td {
background: Canvas; /* sticky cells need a background, or the cells under them show through */
} Give sticky cells a background, or the cells that scroll under them show through, and lift the corner cell with a
z-index so the other cells pass beneath it. Canvas is the system colour of the page’s background, so it follows a
dark colour scheme too.
Sticky headers and keyboard focus
A sticky header covers whatever scrolls under it, including the element that has keyboard focus. When focus moves, the browser scrolls only if the newly focused element is outside the viewport, and an element that is inside it but under the header counts as visible, so it isn’t scrolled at all. Moving backwards with Shift+Tab walks focus straight into that strip. WCAG 2.2 success criterion 2.4.11, Focus Not Obscured (Minimum), at level AA, says a component that receives keyboard focus must not be entirely hidden by content the author created, and a sticky header is its textbook failure. The focus page has a 64 px sticky header above 40 buttons and logs where each focused button lands:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Keyboard focus under a sticky header</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.site-header { position: sticky; inset-block-start: 0; block-size: 64px; background: #dde; }
.list button { display: block; block-size: 40px; }
/* Remove this rule to see the problem: */
html { scroll-padding-block-start: 64px; }
</style>
</head>
<body>
<header class="site-header">Site header</header>
<div class="list" id="list"></div>
<script>
const list = document.getElementById('list');
for (let i = 0; i < 40; i++) list.insertAdjacentHTML('beforeend', `<button type="button">Item ${i}</button>`);
// Each time focus moves, log where the focused button is against the bottom of the 64px header.
document.addEventListener('focusin', (event) => {
const r = event.target.getBoundingClientRect();
const covered = Math.max(0, Math.min(r.bottom, 64) - Math.max(r.top, 0));
const state = covered >= r.height ? 'entirely hidden' : covered > 0 ? `${covered}px hidden` : 'visible';
console.log(`${event.target.textContent}: top ${Math.round(r.top)}px, ${state}`);
});
</script>
</body>
</html> Pressing Shift+Tab 16 times from the bottom of the page without scroll-padding left one button entirely under the
header, “Item 25” at 0 px, and another with 24 of its 40 px hidden, in both Chromium and Firefox. With
scroll-padding-block-start: 64px on html, which is scroll-padding-top in horizontal writing, not one button was
hidden: the browser treats the padded strip as outside the visible area whenever it scrolls something into view.
WebKit is left out of this measurement, because in this run its Tab key didn’t move focus to buttons at all.
/* Keep focused and linked elements out from under a sticky header (WCAG 2.2, success criterion 2.4.11). */
:root {
--header-height: 64px;
}
.site-header {
position: sticky;
inset-block-start: 0;
block-size: var(--header-height);
}
html {
/* The browser leaves this much room at the top when it scrolls something into view:
tabbing to a link, following a #fragment, or scrollIntoView(). */
scroll-padding-block-start: calc(var(--header-height) + 8px);
} scroll-padding also applies to links to a fragment such as #pricing and to scrollIntoView(), and a few pixels
more than the header’s height keep the focus outline clear of it. If the header’s height changes, for example when a
menu opens inside it, update the custom property with it. WCAG’s technique C43 describes the same fix.
Practise: find the containing block
The exercise turns the trap table into a function, and its tests are the lab’s rows.
Exercise · Medium · JavaScript
Find the containing block of a positioned box
A "fixed" button that scrolls away, or a badge that lands in the corner of the page instead of its card, is a containing block question. Write containingBlock(position, ancestors) in containing.mjs and export it.
positionis the box's ownposition:'static','relative','sticky','absolute'or'fixed'.ancestorslists the box's ancestors from its parent outwards. Each is an object of CSS properties written as strings, such as{ position: 'relative' }or{ transform: 'translateY(0)', opacity: '0.5' }. A property that is missing has its initial value:staticforposition,nonefor the others.
Return the index of the ancestor whose padding box is the containing block, following these rules:
- A
static,relativeorstickybox takes its containing block from its parent: return0. - An ancestor traps absolute and fixed boxes when it has
transform,translate,rotate,scale,perspective,filterorbackdrop-filterset to anything butnone;containwithlayoutorpaintamong its words, orstrictorcontent;content-visibility: auto;transform-style: preserve-3d; orwill-changenaming one oftransform,translate,rotate,scale,perspective,filterorbackdrop-filterin its comma-separated list. - An
absolutebox uses the nearest ancestor that traps it or whosepositionis notstatic. Afixedbox uses the nearest ancestor that traps it; its ancestors'positiondoesn't matter. - When no ancestor qualifies, return
-1: the initial containing block for an absolute box, the viewport for a fixed one.
Nothing else traps: not opacity, isolation, clip-path, overflow, z-index, container-type or contain: size. The sample tests import the function from containing.mjs and run in your browser.
Starter code · containing.mjs
/** The index of the ancestor whose padding box is the containing block, or -1. */
export function containingBlock(position, ancestors) {
// Replace this line with your code.
return -1;
} The sample tests · containing.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { containingBlock } from './containing.mjs';
test('static, relative and sticky boxes use their parent', () => {
assert.equal(containingBlock('static', [{}, { position: 'relative' }]), 0);
assert.equal(containingBlock('relative', [{}]), 0);
assert.equal(containingBlock('sticky', [{}, { transform: 'scale(2)' }]), 0);
});
test('an absolute box uses the nearest positioned ancestor', () => {
assert.equal(containingBlock('absolute', [{}, {}]), -1);
assert.equal(containingBlock('absolute', [{}, { position: 'relative' }, {}]), 1);
assert.equal(containingBlock('absolute', [{ position: 'sticky' }, { position: 'relative' }]), 0);
assert.equal(containingBlock('absolute', [{ position: 'static' }, { position: 'fixed' }]), 1);
});
test('a fixed box ignores position but not transforms', () => {
assert.equal(containingBlock('fixed', [{ position: 'relative' }, {}]), -1);
assert.equal(containingBlock('fixed', [{}, { transform: 'translateY(0)' }]), 1);
assert.equal(containingBlock('fixed', [{ translate: '0' }]), 0);
assert.equal(containingBlock('fixed', [{ transform: 'none' }, { rotate: '0deg' }]), 1);
});
test('filters, perspective and containment trap both kinds of box', () => {
assert.equal(containingBlock('fixed', [{ filter: 'blur(0)' }]), 0);
assert.equal(containingBlock('fixed', [{}, { 'backdrop-filter': 'blur(4px)' }]), 1);
assert.equal(containingBlock('fixed', [{ perspective: '500px' }]), 0);
assert.equal(containingBlock('fixed', [{ contain: 'paint' }]), 0);
assert.equal(containingBlock('fixed', [{ contain: 'layout style' }]), 0);
assert.equal(containingBlock('fixed', [{ contain: 'strict' }]), 0);
assert.equal(containingBlock('fixed', [{ contain: 'size' }, {}]), -1);
assert.equal(containingBlock('absolute', [{ 'content-visibility': 'auto' }]), 0);
});
test('will-change traps only when it names a trapping property', () => {
assert.equal(containingBlock('fixed', [{ 'will-change': 'opacity' }]), -1);
assert.equal(containingBlock('fixed', [{ 'will-change': 'opacity, transform' }]), 0);
assert.equal(containingBlock('fixed', [{ 'will-change': 'top' }, { 'will-change': 'filter' }]), 1);
});
test('look-alikes trap nothing', () => {
const lookAlikes = [{ opacity: '0.5' }, { isolation: 'isolate' }, { 'clip-path': 'inset(0)' }, { overflow: 'hidden' },
{ 'z-index': '10' }, { 'container-type': 'inline-size' }];
assert.equal(containingBlock('fixed', lookAlikes), -1);
assert.equal(containingBlock('absolute', lookAlikes), -1);
}); A hint
Write one helper, traps(style), that checks the list of properties in the prompt; for contain and will-change, split the value into words first. Then loop over ancestors with its index and return the first i for which traps(ancestors[i]) is true, or, for an absolute box, (ancestors[i].position ?? 'static') !== 'static'.
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 position: absolute and position: fixed?
Both take the box out of normal flow, so it takes no space, and both place it with the inset properties. An absolute
box is placed against its containing block: the padding box of its nearest ancestor whose position isn’t static,
or the initial containing block when there is none, so it scrolls with that ancestor. A fixed box is placed against the
viewport and stays put while the page scrolls. The exception catches people out: an ancestor with a transform, a
filter, perspective, contain: paint or will-change: transform becomes the containing block of fixed descendants
too, so a fixed button inside a transformed wrapper scrolls with it. In a measured case, a back-to-top button sat 16 px
above the bottom of the viewport until an animation left transform: translateY(0) on its wrapper, and then it moved
with the page.
Key takeaways
relativeshifts a box on screen and keeps its space;absolutetakes it out of the flow and places it against the padding box of its nearest positioned ancestor, or the initial containing block.inset: 0withmargin: autocentres a positioned box that has a size; without a size the box fills its containing block.- A fixed box uses the viewport unless an ancestor has a transform,
translate, a filter,backdrop-filter,perspective,contain: layoutorpaint,content-visibility: autoor awill-changethat names one of them, even with identity values. stickyneeds an inset, sticks within its nearest scrollport and never leaves its parent; a sticky flex item needsalign-self: start.- For tables, make the
thcells sticky inside a box withoverflow: auto, and give them a background. - Set
scroll-padding-block-startonhtmlto the sticky header’s height plus a little, so focus never ends up hidden under it.
References
- CSS Positioned Layout Module Level 3: the position property (W3C CSS Working Group)
- CSS Positioned Layout Module Level 3: containing blocks of positioned boxes (W3C CSS Working Group)
- CSS Positioned Layout Module Level 3: box insets and the inset shorthand (W3C CSS Working Group)
- CSS Positioned Layout Module Level 3: sticky positioning (W3C CSS Working Group)
- CSS Positioned Layout Module Level 3: resolving insets of absolutely positioned boxes (W3C CSS Working Group)
- CSS Positioned Layout Module Level 4: the top layer (W3C CSS Working Group)
- CSS Transforms Module Level 1: transform rendering model (W3C CSS Working Group)
- Filter Effects Module Level 1: the filter property (W3C CSS Working Group)
- CSS Containment Module Level 2: layout containment (W3C CSS Working Group)
- CSS Will Change Module Level 1: the will-change property (W3C CSS Working Group)
- CSS Scroll Snap Module Level 1: the scroll-padding properties (W3C CSS Working Group)
- WCAG 2.2: success criterion 2.4.11 Focus Not Obscured (Minimum) (W3C Web Accessibility Initiative)
- Understanding SC 2.4.11: Focus Not Obscured (Minimum) (W3C Web Accessibility Initiative)
- Technique C43: using CSS scroll-padding to un-obscure content (W3C Web Accessibility Initiative)
- position (MDN Web Docs) (Mozilla)
- Layout and the containing block (MDN Web Docs) (Mozilla)
- Sticky positioning (web-features) (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress