Make Tab follow the layout with CSS reading-flow

order and grid placement move items on screen, but Tab still walks the HTML. reading-flow on the container tells the browser to follow the layout instead.

To make keyboard Tab follow what people see in a flex or grid layout, add reading-flow to the container.

Use flex-visual on a flex container, and grid-rows or grid-columns on a grid. The HTML stays as it is, and focus visits the children in layout order.

Try it. Box C is moved to the front with order: -1. Click Start, press Tab, then turn the checkbox on and do it again.

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>reading-flow: flex-visual</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 14px; align-items: center; font-size: 13px; }
  .badge { padding: 3px 8px; border-radius: 99px; font-weight: 700; font-size: 12px; }
  .yes { background: #d6f2df; color: #0f5132; } .no { background: #fde2da; color: #9a3412; }

  /* the flex container: C is moved to the front with order */
  .row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; padding: 10px;
         background: #fff; border: 1px solid #d5d9e0; border-radius: 10px; }
  .row.visual { reading-flow: flex-visual; }  /* Tab follows what you see */
  .row button { width: 64px; height: 56px; border: 0; border-radius: 10px; color: #fff;
                font: 700 18px system-ui, sans-serif; cursor: pointer; }
  .row button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }
  #c { order: -1; }
  .start { margin-top: 10px; font: inherit; font-size: 13px; }
  pre { margin: 10px 0 0; padding: 10px; background: #fff; border-radius: 10px;
        font: 12.5px/1.6 ui-monospace, Consolas, monospace; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="bar">
  <label><input type="checkbox" id="toggle"> <code>reading-flow: flex-visual</code></label>
  <span class="badge" id="support"></span>
</div>

<button class="start" id="start">Start here, then press Tab</button>
<div class="row" id="row">
  <button style="background:#2563eb">A</button>
  <button style="background:#0f766e">B</button>
  <button style="background:#c2410c" id="c">C</button>
  <button style="background:#7c3aed">D</button>
</div>
<pre id="out"></pre>

<script>
  const row = document.getElementById('row');
  const out = document.getElementById('out');
  const ok = CSS.supports('reading-flow', 'flex-visual');
  const badge = document.getElementById('support');
  badge.textContent = ok ? 'This browser supports reading-flow' : 'This browser ignores reading-flow';
  badge.className = 'badge ' + (ok ? 'yes' : 'no');

  let path = [];
  function show() {
    out.textContent = 'HTML order:   A B C D\n' +
      'Visual order: C A B D\n' +
      'Tab path:     ' + (path.join(' ') || '(click Start, then press Tab)');
  }

  row.addEventListener('focusin', (e) => { path.push(e.target.textContent); show(); });
  document.getElementById('start').addEventListener('focus', () => { path = []; show(); });
  document.getElementById('toggle').addEventListener('change', (e) => {
    row.classList.toggle('visual', e.target.checked);
    path = []; show();
  });
  show();
</script>
</body>
</html>
Four buttons in a flex row. C has order: -1. The checkbox adds reading-flow: flex-visual to the row.

The badge at the top tells you whether your browser applies reading-flow. If it says the property is ignored, the Tab path stays A B C D with the box on or off.

Why Tab jumps around, and the fix

order, flex-direction: row-reverse, grid-row, grid-column and grid-area all change where an item is drawn. None of them change the HTML. Keyboard focus follows the HTML, so the focus ring can jump backwards and forwards across the screen.

Same HTML. Without reading-flow, focus jumps back to C. With it, focus moves left to right.
Same HTML. Without reading-flow, focus jumps back to C. With it, focus moves left to right.

For someone using a mouse, this never shows. For someone moving through the page with Tab, the next stop can land somewhere they did not expect. The CSS order guide covers how order sorts items and where it is still safe to use.

The fix is one property. reading-flow goes on the flex or grid container, not on the items.

.toolbar {
  display: flex;
  reading-flow: flex-visual;
}
.toolbar .help { order: -1; }

It only affects the container's direct children. A button nested inside a moved child goes along with that child. In our Chromium test, two buttons inside one grid item stayed together, in their HTML order, at the item's place in the flow.

Shift+Tab walks the same path backwards. Elements with tabindex="-1" stay out of the path, as they do everywhere else. The tabindex guide explains why a positive tabindex is not a good way to patch the order instead.

The values, flex and grid

The flex values work only on flex containers, and the grid values only on grid containers. source-order also works on a plain block container.

Value Container Tab order
normal any HTML order (the default)
flex-visual flex Visual order, in the writing direction
flex-flow flex The flex-flow direction
grid-rows grid Row by row, then left to right
grid-columns grid Column by column, then top to bottom
grid-order grid HTML order adjusted by order and reading-order
source-order block, flex, grid HTML order adjusted by reading-order

flex-visual and flex-flow only differ when the direction is reversed. In our test with row-reverse and items X1, X2, X3, flex-visual went X3, X2, X1, left to right as drawn. flex-flow went X1, X2, X3, starting from the right-hand edge where the flex line begins.

In a grid, placement properties can put any item in any cell. Pick grid-rows when the page reads across, and grid-columns when each column is a group read top to bottom.

The same six items. Big numbers are the HTML position, circles are the Tab stops.
The same six items. Big numbers are the HTML position, circles are the Tab stops.

Switch the values below and press Tab through the grid. Item 1 comes first in the HTML but is drawn in the bottom right corner.

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>reading-flow values in a grid</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 12px; align-items: center; font-size: 13px; }
  select, .start { font: inherit; font-size: 13px; }
  .note { font-size: 12px; color: #9a3412; }

  /* six buttons placed out of source order */
  .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 10px;
          padding: 10px; background: #fff; border: 1px solid #d5d9e0; border-radius: 10px; }
  .grid button { height: 58px; border: 0; border-radius: 10px; background: #2563eb; color: #fff;
                 font: 700 15px system-ui, sans-serif; cursor: pointer; }
  .grid button small { display: block; font: 600 10px ui-monospace, Consolas, monospace; opacity: .85; }
  .grid button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }
  #b1 { grid-area: 2 / 3; }   /* first in HTML, drawn bottom right */
  #b2 { grid-area: 1 / 2; }
  .grid.pull #b5 { reading-order: -1; }  /* ignored while reading-flow is normal */
  pre { margin: 10px 0 0; padding: 10px; background: #fff; border-radius: 10px;
        font: 12.5px/1.6 ui-monospace, Consolas, monospace; white-space: pre-wrap; }
</style>
</head>
<body>
<div class="bar">
  <label><code>reading-flow:</code>
    <select id="mode">
      <option>normal</option>
      <option>grid-rows</option>
      <option>grid-columns</option>
      <option>grid-order</option>
    </select>
  </label>
  <label><input type="checkbox" id="pull"> <code>reading-order: -1</code> on 5</label>
  <button class="start" id="start">Start here, then press Tab</button>
  <span class="note" id="note"></span>
</div>

<div class="grid" id="grid">
  <button id="b1">1<small>HTML 1st</small></button>
  <button id="b2">2<small>HTML 2nd</small></button>
  <button id="b3">3<small>HTML 3rd</small></button>
  <button id="b4">4<small>HTML 4th</small></button>
  <button id="b5">5<small>HTML 5th</small></button>
  <button id="b6">6<small>HTML 6th</small></button>
</div>
<pre id="out"></pre>

<script>
  const grid = document.getElementById('grid');
  const out = document.getElementById('out');
  const mode = document.getElementById('mode');
  if (!CSS.supports('reading-flow', 'grid-rows')) {
    document.getElementById('note').textContent = 'This browser ignores reading-flow: every value gives HTML order.';
  }

  let path = [];
  function show() {
    out.textContent = 'reading-flow: ' + mode.value +
      (grid.classList.contains('pull') ? ', item 5 has reading-order: -1' : '') + '\n' +
      'Tab path: ' + (path.join(' ') || '(click Start, then press Tab)');
  }
  grid.addEventListener('focusin', (e) => { path.push(e.target.id.slice(1)); show(); });
  document.getElementById('start').addEventListener('focus', () => { path = []; show(); });
  document.getElementById('pull').addEventListener('change', (e) => {
    grid.classList.toggle('pull', e.target.checked);
    path = []; show();
  });
  mode.addEventListener('change', () => {
    grid.style.readingFlow = mode.value;
    path = []; show();
  });
  show();
</script>
</body>
</html>
Six buttons, two placed with grid-area and four placed automatically. The checkbox gives item 5 reading-order: -1.

reading-order for single items

reading-order goes on one child. It takes a whole number, like order, and moves that child earlier (negative) or later (positive) in the reading sequence.

.grid { display: grid; reading-flow: source-order; }
.grid .skip-link { reading-order: -1; }

It counts whenever the container's reading-flow is not normal. Children are sorted by reading-order first, and the reading-flow value decides the order among equal values. In the grid example, tick the checkbox: item 5 jumps to the front for every value except normal.

Browser support we measured

We tested on 26 September 2026 by pressing Tab in real browsers through Playwright, and by checking CSS.supports().

  • Applied reading-flow and reading-order: the Chromium build that ships with Playwright, Google Chrome and Microsoft Edge.
  • Ignored both: the Firefox and WebKit builds that ship with Playwright. Tab followed the HTML for every value, and CSS.supports() returned false.

WebKit is the engine behind Safari. We did not test Safari itself, screen readers, or phones. Support changes over time, so check an up-to-date compatibility table before you rely on it.

A fallback that stays consistent

A browser that does not know reading-flow still applies order and grid placement. The safe pattern is to move items only where reading-flow is available, and to write the HTML in a sensible reading order for everyone else.

Inside @supports, the card moves and Tab follows. Without support, nothing moves and the HTML order still matches Tab.
Inside @supports, the card moves and Tab follows. Without support, nothing moves and the HTML order still matches Tab.
  1. Put the HTML in the order that makes sense when nothing is moved.
  2. Add reading-flow to the container.
  3. Put the order or grid placement rules inside an @supports block for reading-flow.
  4. Press Tab through it in a browser that supports it and one that does not.
.plans { display: grid; grid-template-columns: repeat(3, 1fr); }

@supports (reading-flow: grid-rows) {
  .plans { reading-flow: grid-rows; }
  .plans .team { grid-column: 2; grid-row: 1; }
}

When the layout can match the HTML at every screen size, you do not need any of this. Named areas with grid-template-areas can often keep the HTML in reading order and still rearrange the page.

A finished example: pricing cards

The HTML lists Basic, Pro, Team. On wide screens Team moves to the middle. In the narrow layout it moves to the top. Both moves sit inside @supports, and the panel compares the visual order with your Tab path.

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>Pricing cards with reading-flow</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 14px; align-items: center; font-size: 13px; margin-bottom: 10px; }
  .start { font: inherit; font-size: 13px; }

  /* HTML order is Basic, Team, Pro. The layout puts Team in the middle. */
  .plans { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
  .plan { background: #fff; border: 1px solid #d5d9e0; border-radius: 12px; padding: 12px; }
  .plan h3 { margin: 0 0 4px; font-size: 15px; }
  .plan p { margin: 0 0 10px; font-size: 13px; color: #5b6270; }
  .plan button { display: inline-block; padding: 7px 12px; border-radius: 8px; background: #1d2330;
            color: #fff; border: 0; font: inherit; font-size: 13px; cursor: pointer; }
  .plan button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 2px; }
  .team { border: 2px solid #2563eb; }
  .team button { background: #2563eb; }

  /* narrow layout: one column, recommended plan first */
  .plans.narrow { grid-template-columns: 1fr; }

  /* reorder only where Tab can follow; other browsers keep the HTML order */
  @supports (reading-flow: grid-rows) {
    .plans { reading-flow: grid-rows; }
    .plans:not(.narrow) .team { grid-column: 2; grid-row: 1; }
    .plans.narrow .team { order: -1; }
  }

  pre { margin: 10px 0 0; padding: 10px; border-radius: 10px; background: #fff;
        font: 12.5px/1.6 ui-monospace, Consolas, monospace; white-space: pre-wrap; }
  pre.match { border-left: 5px solid #16a34a; } pre.miss { border-left: 5px solid #ea580c; }
</style>
</head>
<body>
<div class="bar">
  <label><input type="checkbox" id="narrow"> Narrow layout</label>
  <button class="start" id="start">Start here, then press Tab</button>
</div>

<div class="plans" id="plans">
  <div class="plan"><h3>Basic</h3><p>One person, three pages.</p><button type="button">Choose Basic</button></div>
  <div class="plan"><h3>Pro</h3><p>Custom domain and more pages.</p><button type="button">Choose Pro</button></div>
  <div class="plan team"><h3>Team</h3><p>Recommended. Shared editing.</p><button type="button">Choose Team</button></div>
</div>
<pre id="out"></pre>

<script>
  const plans = document.getElementById('plans');
  const out = document.getElementById('out');
  const cards = [...plans.querySelectorAll('.plan')];
  const name = (el) => el.querySelector('h3').textContent;
  let path = [];

  // visual order: top to bottom, then left to right
  function visualOrder() {
    return [...cards].sort((a, b) => {
      const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect();
      return Math.abs(ra.top - rb.top) > 4 ? ra.top - rb.top : ra.left - rb.left;
    }).map(name);
  }

  function show() {
    const seen = visualOrder();
    const done = path.length >= cards.length;
    const same = done && path.slice(0, cards.length).join() === seen.join();
    out.className = done ? (same ? 'match' : 'miss') : '';
    out.textContent =
      'reading-flow supported: ' + (CSS.supports('reading-flow', 'grid-rows') ? 'yes' : 'no') + '\n' +
      'Visual order: ' + seen.join(' > ') + '\n' +
      'Tab path:     ' + (path.join(' > ') || '(click Start, then press Tab)') +
      (done ? (same ? '\nTab follows what you see.' : '\nTab does not follow what you see.') : '');
  }

  plans.addEventListener('focusin', (e) => { path.push(name(e.target.closest('.plan'))); show(); });
  document.getElementById('start').addEventListener('focus', () => { path = []; show(); });
  document.getElementById('narrow').addEventListener('change', (e) => {
    plans.classList.toggle('narrow', e.target.checked);
    path = []; show();
  });
  show();
</script>
</body>
</html>
Press Tab through the three links in the wide and narrow layouts. Green means the Tab path matched the visual order.
  • Supporting browsers: Team is in the middle, or on top when narrow, and Tab visits it there.
  • Other browsers: the cards stay Basic, Pro, Team. The layout is less polished, but Tab and the screen agree.

When it does not work

What you see Cause Fix
Tab still follows the HTML The browser does not support reading-flow Wrap the reordering in @supports so the layout matches the HTML there
No effect in a supporting browser The value does not match the layout, such as grid-rows on flex flex-visual for flex, grid-rows or grid-columns for grid
No effect on a nested link reading-flow only reorders direct children Put it on the container whose children are moved
reading-order is ignored The container has no reading-flow, or normal Set reading-flow on the parent, such as source-order
Reversed row tabs right to left flex-flow follows the flex direction Use flex-visual
Focus jumps to a far corner first A positive tabindex somewhere on the page Remove it, use 0 or -1

Tab order is something people need to try, not read about. A screenshot cannot be tabbed through, and an .html attachment may open as plain code on a phone.

To send a working version, 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.

The people you send it to can press Tab through it in their own browser. If you change the code later, the same link shows the new version.

Questions people ask

What does reading-flow do?

It is set on a flex or grid container and changes the order in which keyboard focus visits the container's children. With flex-visual, grid-rows or grid-columns, Tab follows the layout on screen instead of the HTML source order.

Which browsers support reading-flow?

In our tests in September 2026, the Chromium build that ships with Playwright, Google Chrome and Microsoft Edge applied it. The Firefox and WebKit builds that ship with Playwright ignored it. Check current support before relying on it, and design the page so it still works without it.

What is the difference between reading-flow and reading-order?

reading-flow goes on the container and switches the behaviour on. reading-order goes on a child and moves that one child earlier or later. reading-order has an effect whenever the container's reading-flow is set to anything other than normal.

Does reading-flow work on a normal block container?

Only with source-order. The flex values only apply to flex containers and the grid values only to grid containers. In our Chromium test, flex-visual on a plain div changed nothing, while source-order on a plain div let reading-order move one button ahead of another.

Should I use reading-flow or fix the HTML order?

Fix the HTML order whenever the same order works for every screen size. reading-flow is for layouts where the visual order has to differ between sizes, and it is safest when the reordering itself is only applied where reading-flow is supported.

Keep reading