Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

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.

relative and absolute positioning, measured HTML · offsets.html
<!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:

A fixed button inside an animated page wrapper HTML · fixed-trap.html
<!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 at matrix(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 to none, 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:

Which ancestor styles capture absolute and fixed boxes HTML · cb-traps.html
<!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: transform and content-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-size and z-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:

Finding the containing block: the nearest ancestor with a transform-like property or, for absolute boxes, any position.A box with position: absolute or fixed.Check its ancestors one by one, nearest first1. 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?Yes: it is the containing block, for both kinds of box2. For an absolute box only:is the ancestor's position anything but static?Yes: it is the containing blockNo to both: move up to the next ancestorNo ancestor matched:an absolute box uses the initial containing block,a fixed box the viewportThe containing block is the ancestor's padding box,just inside its bordernonoafter the last ancestor

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.

  1. 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.
  2. If not, and the box is absolutely positioned, is the ancestor's position anything other than static? If so, it is the containing block.
  3. If neither applies, the browser moves up to the next ancestor and asks again.
  4. 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:

Sticky boxes measured while the page scrolls HTML · sticky.html
<!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:

Sticky positioning as arithmetic JavaScript · sticky_offset.mjs
// 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

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 sticky header row and first column CSS · sticky-table.css
/* 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:

Where a focused button lands under a sticky header HTML · focus-under-header.html
<!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.

Leaving room for a sticky header CSS · scroll-padding.css
/* 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.

HTML, CSS & JS Playground (Online Editor) Paste the sticky or the focus page into the HTML pane, then scroll and press Shift+Tab.

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.

  • position is the box's own position: 'static', 'relative', 'sticky', 'absolute' or 'fixed'.
  • ancestors lists 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: static for position, none for the others.

Return the index of the ancestor whose padding box is the containing block, following these rules:

  • A static, relative or sticky box takes its containing block from its parent: return 0.
  • An ancestor traps absolute and fixed boxes when it has transform, translate, rotate, scale, perspective, filter or backdrop-filter set to anything but none; contain with layout or paint among its words, or strict or content; content-visibility: auto; transform-style: preserve-3d; or will-change naming one of transform, translate, rotate, scale, perspective, filter or backdrop-filter in its comma-separated list.
  • An absolute box uses the nearest ancestor that traps it or whose position is not static. A fixed box uses the nearest ancestor that traps it; its ancestors' position doesn'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'.

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.

  1. Question 1 of 6 A badge has position: absolute; top: 0; left: 0. Its parent is position: static, its grandparent position: relative, and the grandparent has a 5 px border. Where does the badge go?

    Choose one answer.

    Show the answer to question 1

    Answer: To the top-left corner of the grandparent, just inside its border

    An absolutely positioned box is placed against its containing block, the padding box of its nearest ancestor whose position is not static. The static parent is skipped, and the padding box starts just inside the border: in the lesson's run a badge with 8 px insets sat 13 px from the card's outer edges, 8 px plus the 5 px border.

  2. Question 2 of 6 A fixed "Back to top" button scrolls away with the page after a page-load animation is added to the page wrapper. Which leftover style is the likely cause?

    Choose one answer.

    Show the answer to question 2

    Answer: transform: translateY(0) kept on the wrapper by the animation's fill mode

    Any transform other than none, even one that moves nothing, makes the element the containing block of its fixed descendants, so the button is fixed to the 3,000 px wrapper and scrolls with it. With animation-fill-mode: both the last frame stays applied after the animation ends; with backwards it doesn't, and the measured button stayed 16 px above the bottom of the viewport again. Opacity, position and isolation don't capture fixed boxes.

  3. Question 3 of 6 Which of these, on an ancestor, become the containing block of a position: fixed descendant?

    Choose every answer that is right.

    Show the answer to question 3

    Answer:

    • will-change: transform
    • filter: blur(0)
    • contain: paint

    A filter, even a zero blur, a will-change that names transform, and layout or paint containment all trap fixed descendants, as Chromium, Firefox and WebKit measured. Opacity creates a stacking context but no containing block, container-type applies only style and size containment, and overflow: hidden clips without capturing fixed boxes.

  4. Question 4 of 6 A heading 40 px tall has position: sticky; top: 64px. Its section's content box now ends 90 px below the top of the viewport, and the heading would be at −300 px in normal flow. Where is the top of the heading, in px from the top of the viewport?

    Type a number.

    Show the answer to question 4

    Answer: 50 px

    Sticky pushes the heading down to 64 px, the larger of −300 and 64. But it must stay inside its containing block, so its top can be at most 90 − 40 = 50 px. The smaller of 64 and 50 wins: the end of the section is already pushing the heading up and out, as at 620 px of scrolling in the lesson's run.

  5. Question 5 of 6 A sidebar has position: sticky and no ancestor has an overflow other than visible, yet it scrolls away like any other box. It sits in a flex row and has top: 64px. What is the most likely cause?

    Choose one answer.

    Show the answer to question 5

    Answer: The flex row stretches the sidebar to the row's full height, so it has no room to move

    A sticky box stays inside its containing block. Flex items stretch to the height of the row by default, so a sidebar as tall as the row can't move without leaving it, and it scrolled away in all three engines. align-self: start gives it its own height, and it then stayed 64 px from the top.

  6. Question 6 of 6 A dialog box has position: fixed; inset: 0; margin: auto; width: 400px; height: 300px; in a viewport 1,280 px wide. How far is its left edge from the left of the viewport?

    Type a number.

    Show the answer to question 6

    Answer: 440 px

    inset: 0 makes the area it may use the whole viewport, and auto margins share the space that the 400 px width leaves: (1,280 − 400) ÷ 2 = 440 px on each side. Without a width the box would stretch to fill the area instead, as the stretched box of the lesson's run did.

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

  • relative shifts a box on screen and keeps its space; absolute takes it out of the flow and places it against the padding box of its nearest positioned ancestor, or the initial containing block.
  • inset: 0 with margin: auto centres 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: layout or paint, content-visibility: auto or a will-change that names one of them, even with identity values.
  • sticky needs an inset, sticks within its nearest scrollport and never leaves its parent; a sticky flex item needs align-self: start.
  • For tables, make the th cells sticky inside a box with overflow: auto, and give them a background.
  • Set scroll-padding-block-start on html to the sticky header’s height plus a little, so focus never ends up hidden under it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.