# Floating buttons and panels that keyboard focus reaches last

Source: https://easeweb.dev/learn/floating-order
Topics: Dialogs and overlays > Floating buttons and their panels come where they belong in the focus order (WCAG 2.4.3)
The fix: Write the floating button where its content is, and its panel straight after it. Either put the sheet in the markup right after the button, or make it a popover that the button opens with popovertarget, which browsers put next in the Tab order wherever it sits. Moving focus into the sheet when it opens also passes, but Tab and Shift + Tab out of it still go to the end of the page.
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html https://www.w3.org/WAI/WCAG22/Techniques/css/C27 https://www.w3.org/WAI/WCAG22/Techniques/failures/F85 https://www.w3.org/WAI/WCAG22/Techniques/client-side-script/SCR26 https://developer.mozilla.org/en-US/docs/Web/API/Popover_API/Using

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A tour company's booking page is drawn for a phone: "Old town walking tour", "2 hours, from $25 a person", a photo, the meeting point with an Open in maps link, three questions that open with a tap (Is the route step-free?, What if it rains?, Can I cancel?), and a Read all 128 reviews link, then the footer's Help centre, Terms and Contact us. A white bar floats at the bottom of the screen from the moment the page loads, with "From $25" and a Book now button, so booking is always a thumb away. Book now opens a sheet just above the bar to choose a date, with Continue.

A keyboard user loads the page and presses Tab to reach Book now, which they can see. Focus goes to Open in maps instead, then to each question, the reviews link and the three footer links. Book now is the ninth Tab. A screen reader user reading from the top hears the whole page, footer included, before "Book now, button, collapsed".

The developer sees the problem and moves the bar up the markup, after the price, so Book now is the first Tab. A second problem is left behind. Press Enter and the sheet opens; press Tab to reach the first date, and focus goes back to Open in maps. The dates come after the whole page again, nine presses later.

## Why it fails

Both the bar and the sheet are written at the end of the page, after the footer, where booking widgets and component libraries often put whatever floats, so no parent can clip it. CSS draws them at the bottom of the screen, but Tab and a screen reader don't follow the drawing: they move through the page in the order of the markup, and in the markup the bar and the sheet come last. That is the gap technique C27 closes, making the order in the markup match the order on screen.

Success criterion 2.4.3 Focus Order asks that focus moves in an order that keeps the meaning and the task intact. The bar is drawn in front of the page from the first moment, as the page's main action, and on a phone it is the only Book now button there is. Yet it comes after everything, so keyboard and screen reader users work through the whole page to reach what everyone else reaches with one tap. Focus order doesn't have to follow the layout, and the criterion's Understanding page passes navigation written after the main content, so whether a floating control may come last is a judgement about the page. A back to top button or a chat launcher at the end of the page is a different case: they aren't what the page is for, and people expect them there.

The sheet fails the same criterion in the way failure F85 describes: a dialog or menu that a button opens, where focus doesn't move into it and the next Tab doesn't reach it. Moving the button fixes the first failure and leaves this one, because the sheet is still at the end. F85's test has a second half, which this sheet passes: Escape closes it and puts focus back on Book now. On the way to the sheet, focus also passes behind it, so the person loses sight of it too.

## Who is affected

The longer the page, the further away the bar and the sheet are: on a real booking page, with a gallery of photos, a map, a dozen reviews and a full footer, Book now can be dozens of presses away, and the dates as many again.

## What automation and AI miss

Every part is right on its own. Book now is a button with a name and `aria-expanded`, the sheet has a heading, a legend and labelled options, and every control can be reached, so axe has nothing to report. The failure is in the order, and only a person can say what order the page means. EaseWeb's audit now raises a lead for a control fixed to the window that Tab reaches only after the page's footer, leaving out back to top buttons, which belong at the end, and chat launchers and cookie banners, which have their own checks. And it reports the second failure as a finding: it opens the buttons that have `aria-expanded` and `aria-controls`, up to twelve on a page, and reports a panel when focus neither moves into it nor reaches it with the next Tab. A panel that a button opens without saying so, or one inside a third-party frame, is still found only by hand. AI assistants asked for a bar or a sheet that floats over the page can reach for a portal or `document.body.append()` to get it out from under `overflow` and `z-index`, without saying what that does to the order.

## Before: the bar and its sheet after the footer

```css styles.css
/* Room for the bar, so it never covers the end of the page or focus. */
html {
  scroll-padding-bottom: 5rem;
}
body {
  padding-bottom: 5rem;
}
.book-bar {
  position: fixed;
  inset-inline: 1rem;
  bottom: 1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 4rem tall and 1rem above the bottom edge: the 5rem kept for it. */
  height: 4rem;
  padding: 0 1rem;
  border-radius: 1rem;
  background: #fff;
}
.sheet {
  position: fixed;
  inset-inline: 1rem;
  bottom: 5.5rem;
  padding: 1rem;
  border-radius: 1rem;
  background: #fff;
}
```

```html index.html
<main>
  <h1>Old town walking tour</h1>
  <p>2 hours, from $25 a person</p>
  …
</main>
<footer>…</footer>
<div class="book-bar">
  <p>From $25</p>
  <button type="button" class="book-button" aria-expanded="false" aria-controls="booking">Book now</button>
</div>
<div class="sheet" id="booking" hidden>
  <h2>Choose a date</h2>
  <form action="/checkout">
    <fieldset>
      <legend>Date</legend>
      <label><input type="radio" name="date" value="2026-06-13"> Sat 13 June</label>
      <label><input type="radio" name="date" value="2026-06-14"> Sun 14 June</label>
      <label><input type="radio" name="date" value="2026-06-20"> Sat 20 June</label>
    </fieldset>
    <button type="submit">Continue</button>
  </form>
</div>
```

```js script.js
const button = document.querySelector('.book-button');
const sheet = document.querySelector('#booking');

function setOpen(open) {
  sheet.hidden = !open;
  button.setAttribute('aria-expanded', String(open));
}
button.addEventListener('click', () => setOpen(sheet.hidden));
sheet.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    setOpen(false);
    button.focus();
  }
});
// Moving on to the rest of the page closes it, so it never covers what has focus.
sheet.addEventListener('focusout', (event) => {
  const next = event.relatedTarget;
  if (next && next !== button && !sheet.contains(next)) setOpen(false);
});
```

The button, its state, the closing and the room kept for the bar are all written well. Only the place of the bar is wrong: it comes after the footer, so Book now is the ninth Tab, after Open in maps, the three questions, the reviews link and the footer's three links. The sheet comes straight after it, so once Book now is reached, the dates are the next Tab.

## Before: the bar moved up, its sheet still after the footer

```css styles.css
/* Room for the bar, so it never covers the end of the page or focus. */
html {
  scroll-padding-bottom: 5rem;
}
body {
  padding-bottom: 5rem;
}
.book-bar {
  position: fixed;
  inset-inline: 1rem;
  bottom: 1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 4rem tall and 1rem above the bottom edge: the 5rem kept for it. */
  height: 4rem;
  padding: 0 1rem;
  border-radius: 1rem;
  background: #fff;
}
.sheet {
  position: fixed;
  inset-inline: 1rem;
  bottom: 5.5rem;
  padding: 1rem;
  border-radius: 1rem;
  background: #fff;
}
```

```html index.html
<main>
  <h1>Old town walking tour</h1>
  <p>2 hours, from $25 a person</p>
  <div class="book-bar">
    <p>From $25</p>
    <button type="button" class="book-button" aria-expanded="false" aria-controls="booking">Book now</button>
  </div>
  …
</main>
<footer>…</footer>
<div class="sheet" id="booking" hidden>
  <h2>Choose a date</h2>
  <form action="/checkout">
    <fieldset>
      <legend>Date</legend>
      <label><input type="radio" name="date" value="2026-06-13"> Sat 13 June</label>
      <label><input type="radio" name="date" value="2026-06-14"> Sun 14 June</label>
      <label><input type="radio" name="date" value="2026-06-20"> Sat 20 June</label>
    </fieldset>
    <button type="submit">Continue</button>
  </form>
</div>
```

```js script.js
const button = document.querySelector('.book-button');
const sheet = document.querySelector('#booking');

function setOpen(open) {
  sheet.hidden = !open;
  button.setAttribute('aria-expanded', String(open));
}
button.addEventListener('click', () => setOpen(sheet.hidden));
sheet.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    setOpen(false);
    button.focus();
  }
});
// Moving on to the rest of the page closes it, so it never covers what has focus.
sheet.addEventListener('focusout', (event) => {
  const next = event.relatedTarget;
  if (next && next !== button && !sheet.contains(next)) setOpen(false);
});
```

The bar moves up the markup to just after the price, and `position: fixed` still draws it at the bottom of the screen, so Book now is now the first Tab. The sheet stays where it was, after the footer, so with the sheet open, the next Tab from Book now goes to Open in maps, which can be under the sheet, and the first date is reached only after the questions, the reviews link and the footer's three links.

## After: the bar with its content, the sheet straight after it

```css styles.css
/* Room for the bar, so it never covers the end of the page or focus. */
html {
  scroll-padding-bottom: 5rem;
}
body {
  padding-bottom: 5rem;
}
.book-bar {
  position: fixed;
  inset-inline: 1rem;
  bottom: 1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 4rem tall and 1rem above the bottom edge: the 5rem kept for it. */
  height: 4rem;
  padding: 0 1rem;
  border-radius: 1rem;
  background: #fff;
}
.sheet {
  position: fixed;
  inset-inline: 1rem;
  bottom: 5.5rem;
  padding: 1rem;
  border-radius: 1rem;
  background: #fff;
}
```

```html index.html
<main>
  <h1>Old town walking tour</h1>
  <p>2 hours, from $25 a person</p>
  <div class="book-bar">
    <p>From $25</p>
    <button type="button" class="book-button" aria-expanded="false" aria-controls="booking">Book now</button>
  </div>
  <div class="sheet" id="booking" hidden>
    <h2>Choose a date</h2>
    <form action="/checkout">
      <fieldset>
        <legend>Date</legend>
        <label><input type="radio" name="date" value="2026-06-13"> Sat 13 June</label>
        <label><input type="radio" name="date" value="2026-06-14"> Sun 14 June</label>
        <label><input type="radio" name="date" value="2026-06-20"> Sat 20 June</label>
      </fieldset>
      <button type="submit">Continue</button>
    </form>
  </div>
  …
</main>
<footer>…</footer>
```

```js script.js
const button = document.querySelector('.book-button');
const sheet = document.querySelector('#booking');

function setOpen(open) {
  sheet.hidden = !open;
  button.setAttribute('aria-expanded', String(open));
}
button.addEventListener('click', () => setOpen(sheet.hidden));
sheet.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    setOpen(false);
    button.focus();
  }
});
// Moving on to the rest of the page closes it, so it never covers what has focus.
sheet.addEventListener('focusout', (event) => {
  const next = event.relatedTarget;
  if (next && next !== button && !sheet.contains(next)) setOpen(false);
});
```

The bar sits in the markup right after the price, which it repeats, and the sheet right after the bar; nothing else changes, and `position: fixed` draws both in the same places on screen. Book now is the first Tab in the page. With the sheet open, Tab from Book now goes to the first date, Tab past Continue goes on to Open in maps and closes the sheet, so the link with focus is never under it, and Shift + Tab from the dates goes back to Book now. While the sheet is closed, `hidden` keeps it out of the order. A screen reader reading from the top meets the bar after the price, and the sheet next. This is technique C27 for the bar, and technique SCR26, putting content that a control reveals straight after it, for the sheet. It depends on `position: fixed` still meaning the window: a parent with `transform`, `filter` or `contain` makes the bar and the sheet fixed to that parent instead, and a parent's `z-index` can draw later content over them, so check their parents.

## After: the bar with its content, the sheet a popover

```css styles.css
/* Room for the bar, so it never covers the end of the page or focus. */
html {
  scroll-padding-bottom: 5rem;
}
body {
  padding-bottom: 5rem;
}
.book-bar {
  position: fixed;
  inset-inline: 1rem;
  bottom: 1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 4rem tall and 1rem above the bottom edge: the 5rem kept for it. */
  height: 4rem;
  padding: 0 1rem;
  border-radius: 1rem;
  background: #fff;
}
.sheet {
  position: fixed;
  inset-inline: 1rem;
  bottom: 5.5rem;
  padding: 1rem;
  border-radius: 1rem;
  background: #fff;
  /* Undo the popover's own box: centred in the window, fitted to its content, with a border. */
  top: auto;
  width: auto;
  margin: 0;
  border: 0;
}
```

```html index.html
<main>
  <h1>Old town walking tour</h1>
  <p>2 hours, from $25 a person</p>
  <div class="book-bar">
    <p>From $25</p>
    <button type="button" class="book-button" popovertarget="booking">Book now</button>
  </div>
  …
</main>
<footer>…</footer>
<div class="sheet" id="booking" popover>
  <h2>Choose a date</h2>
  <form action="/checkout">
    <fieldset>
      <legend>Date</legend>
      <label><input type="radio" name="date" value="2026-06-13"> Sat 13 June</label>
      <label><input type="radio" name="date" value="2026-06-14"> Sun 14 June</label>
      <label><input type="radio" name="date" value="2026-06-20"> Sat 20 June</label>
    </fieldset>
    <button type="submit">Continue</button>
  </form>
</div>
```

```js script.js
const button = document.querySelector('.book-button');
const sheet = document.querySelector('#booking');

// Moving on to the rest of the page closes it, so it never covers what has focus.
sheet.addEventListener('focusout', (event) => {
  const next = event.relatedTarget;
  if (next && next !== button && !sheet.contains(next)) sheet.hidePopover();
});
```

The bar moves up to just after the price, as in the fix before, and the sheet stays after the footer, where a booking widget or a component library may insist on putting it. The browser puts it next in the Tab order instead: when a button opens a popover through `popovertarget`, the popover's controls come straight after that button. Tab from Book now goes to the first date, and Tab past Continue goes on to Open in maps, where the script's one listener closes the sheet so that it doesn't cover the link. The browser does the rest of what the script did: it closes the sheet on Escape and puts focus back on Book now, reports Book now as expanded or collapsed, with no `aria-expanded` to keep in step, and draws the sheet over everything else on the page, in the top layer, so no parent can clip it or draw over it. A click outside the sheet also closes it. Reading on from Book now in a screen reader's browse mode still meets the sheet at the end of the page, where it is in the markup; browsers link Book now to it, as `aria-details` would, which some screen readers announce. Test the order in every browser you support: Chrome, Firefox and Safari all support `popovertarget`, and WebKit changed how Tab moves through popovers in 2024.

## After: the bar with its content, focus moved into the sheet

```css styles.css
/* Room for the bar, so it never covers the end of the page or focus. */
html {
  scroll-padding-bottom: 5rem;
}
body {
  padding-bottom: 5rem;
}
.book-bar {
  position: fixed;
  inset-inline: 1rem;
  bottom: 1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  /* 4rem tall and 1rem above the bottom edge: the 5rem kept for it. */
  height: 4rem;
  padding: 0 1rem;
  border-radius: 1rem;
  background: #fff;
}
.sheet {
  position: fixed;
  inset-inline: 1rem;
  bottom: 5.5rem;
  padding: 1rem;
  border-radius: 1rem;
  background: #fff;
}
```

```html index.html
<main>
  <h1>Old town walking tour</h1>
  <p>2 hours, from $25 a person</p>
  <div class="book-bar">
    <p>From $25</p>
    <button type="button" class="book-button" aria-expanded="false" aria-controls="booking">Book now</button>
  </div>
  …
</main>
<footer>…</footer>
<div class="sheet" id="booking" hidden>
  <h2>Choose a date</h2>
  <form action="/checkout">
    <fieldset>
      <legend>Date</legend>
      <label><input type="radio" name="date" value="2026-06-13"> Sat 13 June</label>
      <label><input type="radio" name="date" value="2026-06-14"> Sun 14 June</label>
      <label><input type="radio" name="date" value="2026-06-20"> Sat 20 June</label>
    </fieldset>
    <button type="submit">Continue</button>
  </form>
</div>
```

```js script.js
const button = document.querySelector('.book-button');
const sheet = document.querySelector('#booking');

function setOpen(open) {
  sheet.hidden = !open;
  button.setAttribute('aria-expanded', String(open));
}
button.addEventListener('click', () => {
  setOpen(sheet.hidden);
  // The sheet comes after the footer, so take focus to it.
  if (!sheet.hidden) sheet.querySelector('input').focus();
});
sheet.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    setOpen(false);
    button.focus();
  }
});
// Moving on to the rest of the page closes it, so it never covers what has focus.
sheet.addEventListener('focusout', (event) => {
  const next = event.relatedTarget;
  if (next && next !== button && !sheet.contains(next)) setOpen(false);
});
```

The bar moves up to just after the price, and the sheet stays after the footer. Opening it puts focus on the first date, which a screen reader reads with the name of its group, Date, and Escape puts focus back on Book now, so both halves of F85's test pass. What it doesn't change is the order around the sheet: Shift + Tab from the dates goes to Contact us, the footer's last link, closing the sheet on the way, and Tab past Continue leaves the page for the browser's own controls. A keyboard user who steps out of the sheet without closing it lands at the end of the page, and has to find Book now again from there.

## Which option to use

All three put Book now with the price, and all three pass 2.4.3. Moving the sheet straight after the bar is the smallest change, with nothing else to add, and the only one where reading on from Book now in a screen reader also meets the dates next, in every browser. It needs the parents of the bar and the sheet to leave `position: fixed` and their stacking alone. The popover needs no script to open, to close on Escape, or to put focus back, tells a screen reader that Book now is expanded, and draws the sheet over everything wherever its markup sits, so it is the fix when a booking widget, a theme or a component library puts its panels at the end of the page; browse mode still reads the sheet there. Moving focus into the sheet is for a sheet you can neither move nor turn into a popover, such as one a third-party script adds. It is not enough on its own, because Tab and Shift + Tab out of the sheet still go to the end of the page.

## Implementation decisions

Write the floating button where its content is: a Book now bar after the tour's name and price, which it repeats; a Filter button before the products it filters; an Add to bag bar after the product's name and price. Its corner on screen decides nothing for a keyboard or a screen reader. When the page already has its own Book now button near the top, the floating bar is a second way to the same action, and can stay at the end, as long as the page's own button is reached early.

The sheet here is non-modal, because people can still read the tour behind it. If a sheet covers the page so that nothing behind it can be used, make it a modal dialog instead: focus goes into it and stays there until it closes, as in [modals that leave keyboard focus behind them](/learn/modal-focus-management).

Every version here closes the sheet when focus moves from inside it to anything on the page other than Book now, as [search panels that close on Tab and drop keyboard focus](/learn/panel-dismissal) does once focus has left the whole component. Left open, the sheet would cover what Tab moves on to, and could hide the control with focus entirely, which 2.4.11 Focus Not Obscured (Minimum) allows for content the person opened only while they can uncover it without moving focus. The listener reads `relatedTarget`, the element focus is moving to, so a click on the sheet's own padding, which moves focus to no other control, leaves it open, as does Tab out of the page. The bar itself keeps its 5rem clear with `scroll-padding-bottom` and padding at the end of the page, so it never covers focus or the end of the page, as in [floating buy bars that cover the page when zoomed in](/learn/floating-buttons).

Booking widgets and component libraries often render bars, popovers, menus and sheets through a portal, such as React's `createPortal` or Vue's `Teleport`, into the end of `body`, so that no parent clips them. Look for the option to render in place, or to use the native popover. Where neither exists, move focus into the panel, and check that Escape puts focus back on the button.

## Verify the fix

1. From the top of the page, press Tab until you reach the floating button. It comes with its content, before the footer.
2. Press Enter to open the sheet. The next Tab puts focus on its first control, or focus is already there.
3. Tab through the sheet and on past its last control. Focus goes through every control in the sheet, then on to what follows the button on the page, and the sheet closes as it goes, so nothing covers the control with focus.
4. Open the sheet again and Shift + Tab back from its first control. Focus goes to the button.
5. Open the sheet and press Escape. Focus is on the button, and the button is read as collapsed.
6. With a screen reader, read the page from the top. The button is read with its content. Open the sheet and press Tab: the first choice is read next, not after the page and the footer. With the sheet straight after the button in the markup, reading on in browse mode reaches its heading next too; a popover is still read at the end of the page, where its markup is.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the bar, its sheet or the page layout changes, and after a booking widget, component library or third-party script is updated.

## Limits

This is a starting pattern, not a guarantee for every application. The bar, its sheet and the tour are this example's; test every floating button on the page, and every panel one opens, including chat, help and feedback panels, with the longest page it appears on. Whether a floating button belongs at the end of the order is a judgement about the page: a back to top button does, and a page's main action doesn't. A chat panel also has to read its messages out, as in [chat widgets that never announce their messages](/learn/chat-widgets). Popover focus order depends on the browser, so test it in each one you support. Class names in the snippets are placeholders for your own.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
