CSS overflow-anchor and scroll anchoring, with live examples

When something loads above what you are reading, the browser can shift the scroll position so your line stays put. overflow-anchor turns that off, and a chat log needs a few lines of JavaScript on top.

The page jumps when content is added above the part you are reading: an image finishes loading, an ad gets its height, older posts are inserted. Scroll anchoring is the browser feature that stops this. It is on by default, and the CSS property overflow-anchor switches it off.

Scroll down a few posts in the box below, or leave it where it starts, then load posts above.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Insert content above</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }
  button { font: inherit; padding: 8px 12px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  #box {
    height: 250px; overflow-y: auto; position: relative;  /* the scroll container */
    background: #fff; border: 1px solid #d5d9e0; border-radius: 10px;
  }
  .post { padding: 14px 16px; border-bottom: 1px solid #eef0f3; }
  .post.new { background: #fff7e6; }
  #out { font-size: 13px; color: #4b5563; }
</style>
</head>
<body>
<div class="bar">
  <button id="load">Load 3 posts above</button>
  <span id="out">You are reading post 8.</span>
</div>
<div id="box"></div>

<script>
  const box = document.getElementById('box');
  const out = document.getElementById('out');
  let older = 0;

  function post(text, isNew) {
    const p = document.createElement('div');
    p.className = 'post' + (isNew ? ' new' : '');
    p.textContent = text;
    return p;
  }

  for (let i = 1; i <= 20; i++) box.append(post('Post ' + i));
  // start in the middle, at post 8, after the first layout
  requestAnimationFrame(() => box.scrollTop = box.children[7].offsetTop);

  document.getElementById('load').addEventListener('click', () => {
    const before = box.scrollTop;
    for (let i = 0; i < 3; i++) box.prepend(post('Older post ' + (++older), true));
    // no JavaScript adjusts the scroll: the browser does it (scroll anchoring)
    requestAnimationFrame(() => {
      out.textContent = 'scrollTop ' + Math.round(before) + ' -> ' + Math.round(box.scrollTop);
    });
  });
</script>
</body>
</html>
No script touches the scroll position. The browser adds the new height to scrollTop, so Post 8 stays at the top.

In Chromium and Firefox, the readout shows scrollTop growing by the height of the three new posts, and the post you were reading does not move. In the WebKit build we tested (Playwright WebKit 26.4), scrollTop stays the same and the new posts push your place down.

What scroll anchoring does

Without anchoring, the scroll position is a plain number of pixels from the top. Insert 120 pixels of content above the view and the number stays 500, so everything below the insert slides down by 120 pixels.

Same insert, two results. Anchoring adds the inserted height to the scroll position.
Same insert, two results. Anchoring adds the inserted height to the scroll position.

With anchoring, the browser picks an element near the top of the visible area, the anchor node, and notes where it is. After the layout changes, it scrolls by exactly the distance that element moved. In our Chromium and Firefox test, 500 became 620 after a 120-pixel insert.

It works for any scroll container, not only the page. The same rule applies to a div with overflow: auto, which the CSS overflow guide explains.

overflow-anchor: auto vs none

The property has two values. auto is the default and lets an element take part. none excludes it. Put none on the scroll container and anchoring is off for that whole box.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>overflow-anchor: auto vs none</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; gap: 8px; margin-bottom: 10px; }
  button { font: inherit; padding: 8px 12px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  button.alt { background: #e5e7eb; color: #1d2330; }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  h3 { font-size: 13px; margin: 0 0 6px; font-family: ui-monospace, Consolas, monospace; }
  .box {
    height: 220px; overflow-y: auto; position: relative;
    background: #fff; border: 1px solid #d5d9e0; border-radius: 10px;
  }
  .none { overflow-anchor: none; }   /* scroll anchoring switched off */
  .banner { height: 0; background: linear-gradient(135deg, #fde68a, #fca5a5); transition: none; }
  .row { padding: 12px; border-bottom: 1px solid #eef0f3; font-size: 14px; }
  .top { font-size: 12px; color: #4b5563; margin-top: 6px; }
</style>
</head>
<body>
<div class="bar">
  <button id="grow">Banner loads above</button>
  <button id="reset" class="alt">Reset</button>
</div>
<div class="cols">
  <div><h3>overflow-anchor: auto</h3><div class="box" id="a"></div><div class="top" id="ta"></div></div>
  <div><h3>overflow-anchor: none</h3><div class="box none" id="b"></div><div class="top" id="tb"></div></div>
</div>

<script>
  const boxes = [document.getElementById('a'), document.getElementById('b')];
  const labels = [document.getElementById('ta'), document.getElementById('tb')];

  function build(box) {
    box.innerHTML = '<div class="banner"></div>';
    for (let i = 1; i <= 15; i++) box.insertAdjacentHTML('beforeend', '<div class="row">Line ' + i + '</div>');
    box.scrollTop = box.children[5].offsetTop;  // start at line 5
  }

  // which line sits at the top edge of the box right now
  function topLine(box) {
    const edge = box.getBoundingClientRect().top + 1;
    for (const r of box.querySelectorAll('.row')) {
      if (r.getBoundingClientRect().bottom > edge) return r.textContent;
    }
  }

  function report() {
    boxes.forEach((b, i) => labels[i].textContent = 'Top line: ' + topLine(b));
  }

  function reset() { boxes.forEach(build); requestAnimationFrame(report); }

  // an image or ad above the reading position finishes loading and gets its height
  document.getElementById('grow').addEventListener('click', () => {
    boxes.forEach(b => b.querySelector('.banner').style.height = '150px');
    requestAnimationFrame(report);
  });
  document.getElementById('reset').addEventListener('click', reset);
  requestAnimationFrame(reset);  // after the first layout
</script>
</body>
</html>
The same banner loads above line 5 in both boxes. Left keeps line 5 in view, right shifts it down.
Where you set overflow-anchor: none What happens
On a scrolling div No anchoring inside that box
On html or body No anchoring for the page scroll (we measured both)
On some children only Those children are never picked as the anchor node
Nowhere Anchoring is on (the default)

To turn it off for a whole page:

body { overflow-anchor: none; }

You rarely want that. The main reason to switch it off is covered further down: your own script already corrects the position.

When the page still moves

Anchoring does not cover every case, even in the engines that support it.

The three cases we measured where the content still moves.
The three cases we measured where the content still moves.
  • At the very top. When scrollTop is 0, nothing is adjusted. New content above appears, and what you saw moves down. This is deliberate: at the top, the reader sees the new content.
  • Switched off. overflow-anchor: none on the scroller, or on every element that could be the anchor.
  • The engine. Playwright WebKit 26.4 did not adjust, and CSS.supports() returned false for the property. Chromium 147 and Firefox 148 adjusted.

For late images, the fix that works in every engine is to reserve their space before they load. Give the img its width and height attributes, or use aspect-ratio, so nothing above the reader changes height.

A chat log that sticks to the bottom

Chats add content at the end, not above. Anchoring does not help there: a new message below the view does not move anything you can see. What you want is a rule.

Decide before each new message: follow the reader to the end, or leave them where they are.
Decide before each new message: follow the reader to the end, or leave them where they are.
  1. Before appending, check whether the reader is at the bottom.
  2. Append the message.
  3. If they were at the bottom, scroll to the end. If they were not, leave the position and show a New messages button.

The check is one line:

const atBottom = () =>
  log.scrollHeight - log.scrollTop - log.clientHeight < 8;

Test it before appending. After the new message is in, the reader is no longer at the bottom, so the test would always fail.

Loading older messages without a jump

Loading history adds content above the view, which is scroll anchoring territory. The catch is that WebKit did not anchor in our test, so a chat that relies on it jumps there. Correcting the position yourself works in all three engines.

const before = log.scrollHeight;
log.prepend(...olderMessages);
log.scrollTop += log.scrollHeight - before;

Now the browser must not correct it as well. With overflow-anchor left on, Chromium and Firefox moved the view twice in our test: once for anchoring, once for the script. That is why the finished example sets overflow-anchor: none on the log.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat log that sticks to the bottom</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .chat { position: relative; max-width: 460px; margin: 0 auto; background: #fff; border: 1px solid #d5d9e0; border-radius: 12px; overflow: hidden; }
  #log {
    height: 320px; overflow-y: auto; padding: 10px;
    overflow-anchor: none;   /* this script keeps the position itself, in every browser */
  }
  #older { display: block; margin: 0 auto 8px; font: 13px system-ui, sans-serif; padding: 6px 10px; border: 1px solid #d5d9e0; border-radius: 99px; background: #fff; cursor: pointer; }
  .msg { max-width: 75%; margin: 6px 0; padding: 8px 12px; border-radius: 14px; background: #eef1f5; font-size: 14px; line-height: 1.4; }
  .msg.me { margin-left: auto; background: #2563eb; color: #fff; }
  #pill {
    position: absolute; left: 50%; bottom: 62px; transform: translateX(-50%);
    display: none; padding: 6px 12px; border: 0; border-radius: 99px;
    background: #111827; color: #fff; font: 13px system-ui, sans-serif; cursor: pointer;
  }
  form { display: flex; gap: 6px; padding: 8px; border-top: 1px solid #eef0f3; }
  input { flex: 1; min-width: 0; font: inherit; padding: 8px 10px; border: 1px solid #d5d9e0; border-radius: 8px; }
  form button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; }
</style>
</head>
<body>
<div class="chat">
  <div id="log"><button id="older" type="button">Load older messages</button></div>
  <button id="pill" type="button">New messages &darr;</button>
  <form id="form"><input id="text" placeholder="Type a message" autocomplete="off"><button>Send</button></form>
</div>

<script>
  const log = document.getElementById('log');
  const pill = document.getElementById('pill');
  const olderBtn = document.getElementById('older');
  let n = 0, old = 0;

  function bubble(text, mine) {
    const m = document.createElement('div');
    m.className = 'msg' + (mine ? ' me' : '');
    m.textContent = text;
    return m;
  }

  // "at the bottom" = within a few pixels of the end
  const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 8;
  const toBottom = () => { log.scrollTop = log.scrollHeight; pill.style.display = 'none'; };

  // new message at the end: follow it only if the reader was already at the bottom
  function add(text, mine) {
    const stick = atBottom();
    log.append(bubble(text, mine));
    if (stick || mine) toBottom();
    else pill.style.display = 'block';
  }

  // older messages at the start: keep the reader on the same message
  olderBtn.addEventListener('click', () => {
    const before = log.scrollHeight;
    for (let i = 0; i < 5; i++) olderBtn.after(bubble('Older message ' + (++old), i % 2));
    log.scrollTop += log.scrollHeight - before;
  });

  log.addEventListener('scroll', () => { if (atBottom()) pill.style.display = 'none'; });
  pill.addEventListener('click', toBottom);

  document.getElementById('form').addEventListener('submit', (e) => {
    e.preventDefault();
    const input = document.getElementById('text');
    if (input.value.trim()) add(input.value.trim(), true);
    input.value = '';
  });

  for (let i = 0; i < 12; i++) add('Message ' + (++n), i % 3 === 0);
  requestAnimationFrame(toBottom);  // after the first layout
  // a reply arrives every 3 seconds; scroll up and it will not pull you down
  setInterval(() => add('Reply ' + (++n)), 3000);
</script>
</body>
</html>
A reply arrives every 3 seconds. Stay at the bottom and it follows; scroll up and it stays put. Load older messages keeps your place.

Two CSS-only shortcuts

Both work in some cases, and both have limits we measured.

Approach What we saw Limit
flex-direction: column-reverse on the log Starts at the bottom in all three engines; new items stay in view Newest message must come first in the HTML, and scrollTop counts in negative numbers
A 1px anchor element at the end Log follows new messages in Chromium and Firefox Only after the log is already scrolled to the bottom; no effect in WebKit

The anchor element trick excludes every message and leaves only a small element at the end as the anchor:

.log * { overflow-anchor: none; }
.log .anchor { overflow-anchor: auto; height: 1px; }

With column-reverse, screen readers and the Tab key follow the HTML order, which is newest first. The flex-direction guide covers that reversed order.

When it does not work

What you see Cause Fix
Page jumps when an image above loads No space reserved, or an engine without anchoring width/height on the img, or aspect-ratio
New posts above push your place down scrollTop was 0 Expected at the top; anchoring starts once scrolled
Loading older messages overshoots Your script and anchoring both corrected overflow-anchor: none on the log
Chat pulls the reader down while they read Always scrolling to the end Check atBottom() before appending
Chat stops following new messages Checked after appending Check before appending
Infinite scroll loads twice Anchoring fired a scroll event Load on an observer, not on every scroll

For that last row, an IntersectionObserver watching a marker at the end of the list does not depend on scroll events at all.

Scroll behaviour is hard to show in a screenshot: the whole point is what moves and what does not. A link to the working page lets someone scroll, load and watch it for themselves.

To send it, paste the page into a NOS document and choose Create share link. HTML to link walks through it. The page renders as written and its scripts run, so the chat keeps receiving replies for whoever opens it. If you change the code later, the same link shows the new version.

Questions people ask

What does overflow-anchor: auto do?

It is the default. It lets an element take part in scroll anchoring: when content above the visible area grows or shrinks, the browser adjusts the scroll position so what you were looking at stays in the same place. You do not need to write it.

When should I use overflow-anchor: none?

When your own JavaScript already corrects the scroll position, for example when loading older chat messages. With both running, the page moves twice. It is also used on a list where you want new content above to push the old content down.

Why does my page still jump in Safari?

In our test, the WebKit engine (Playwright WebKit 26.4, the engine behind Safari) did not adjust the scroll position and reported no support for overflow-anchor. Reserve space for late content, or correct scrollTop in JavaScript, if the result must match everywhere.

Does scroll anchoring fire a scroll event?

Yes, in the engines that anchor. In our Chromium and Firefox test, one scroll event fired when the browser adjusted the position. Code that loads more content on scroll should not treat that event as the user scrolling.

Can I keep a chat at the bottom with CSS only?

Partly. flex-direction: column-reverse starts the box at the bottom in Chromium, Firefox and WebKit, but the newest message has to come first in the HTML. The JavaScript check shown in this guide keeps normal order and works the same in all three.

Keep reading