# Full-page sections that Page Down and Space can't move

Source: https://easeweb.dev/learn/scroll-hijacking
Topics: Keyboard and focus > Pages scroll with Space, Page Down and the arrow keys (WCAG 2.1.1)
The fix: Either take off the lock and the wheel handler and let the browser scroll, with scroll-snap-type if the sections should still settle into place; or keep the script, give the keys the function the wheel calls, and show the section that takes focus; or give every section a real button, which is the smallest change and leaves the scrolling keys dead.
Test with: keyboard, zoom, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F54 https://www.w3.org/TR/css-scroll-snap-1/ https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type https://developer.mozilla.org/en-US/docs/Web/CSS/overflow https://developer.mozilla.org/en-US/docs/Web/CSS/visibility https://gsap.com/docs/v3/Plugins/Observer/ https://swiperjs.com/swiper-api https://alvarotrigo.com/fullPage/docs/ https://www.nngroup.com/articles/illusion-of-completeness/

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 product page for a pair of headphones is drawn as four full-screen sections: the name, the battery, the repairs, and the price with an "Order now" link. One notch of the mouse wheel, or one swipe, slides the next section into place, and nothing stops halfway. It is a popular look for a launch page, and with a wheel it feels good.

A keyboard user loads the page and presses Space. Nothing moves. Page Down, the down arrow and End do nothing either. Tab goes straight to "Order now" in the last section, because it is the first thing on the page that takes focus, and the battery and repairs sections are passed without ever being on screen.

Nothing on the first screen says there is more. `overflow: hidden` removes the scrollbar, and a section that fills the window leaves nothing cut off at its bottom edge.

## Why it fails

Success criterion 2.1.1 asks that all functionality can be operated through a keyboard. A browser normally gives a page this for free: Space, Page Down, the arrow keys, Home and End scroll any page that can scroll. This page has been told it can't. `overflow: hidden` on `html` and `body` leaves the browser nothing to scroll, and the sections move by a transform that only a `wheel` listener and a swipe set.

The W3C calls this failure F54: pointing-device-specific event handlers as the only way to run a function. The criterion doesn't name particular keys. It asks for a keyboard way to do the same thing, so a page with buttons that choose each section can pass it, as one fix below does. This page has no keyboard way to the middle sections at all.

Tab doesn't rescue it. Sections of plain text have nothing to focus, so Tab can't stop in them. When it reaches the link, the browser scrolls the locked `body` to bring the link into view, which the script knows nothing about, so the next turn of the wheel leaves the page out of step with its own count of sections.

## Who is affected

The page works with a mouse and, where swipes are handled, on a phone, so it can ship without anyone trying the keys.

## What automation and AI miss

The markup is valid and every section's text is in the page, so axe and other markup checks pass it. The EaseWeb audit presses the keys: with focus on the page, it presses Page Down, Space, the down arrow and End, then Tab and Page Down again, and then turns the wheel. Content that moves for the wheel and for no key is reported as `ew:scroll-keys-manual-check`. So is a page whose scrolling stops short of its end while the keys are still pressed. It is a review lead and not a finding, because buttons elsewhere on the page can give the keyboard a way to every section, and code can't weigh them. AI fixes can add a listener for the arrow keys alone, which leaves Space, Page Down, Home and End dead, or add `tabindex="0"` to the sections, which gives Tab somewhere to stop and moves nothing.

## Before: full-screen sections that change on the wheel

```html
<main class="screens">
  <section>
    <h1>Aster One</h1>
    <p>Headphones that fold flat.</p>
  </section>
  <section>
    <h2>Thirty hours on one charge</h2>
    <p>Ten minutes of charging gives you three more.</p>
  </section>
  <section>
    <h2>Made to be mended</h2>
    <p>Every part can be replaced, and the repair guide is free.</p>
  </section>
  <section>
    <h2>$199</h2>
    <p>Free delivery and 30 days to return them.</p>
    <a href="/order">Order now</a>
  </section>
</main>
```

```css
html,
body {
  height: 100%;
  overflow: hidden;
}
.screens {
  transition: transform 0.6s ease;
}
.screens section {
  box-sizing: border-box;
  height: 100vh;
  padding: 2rem;
}
```

```js
const screens = document.querySelector('.screens');
let current = 0;
let moving = false;

function show(index) {
  current = Math.min(Math.max(index, 0), screens.children.length - 1);
  screens.style.transform = `translateY(${current * -100}vh)`;
}

window.addEventListener('wheel', (event) => {
  event.preventDefault();
  if (moving) return;
  moving = true;
  setTimeout(() => (moving = false), 700);
  show(current + Math.sign(event.deltaY));
}, { passive: false });
// A swipe calls show() the same way.
```

- `overflow: hidden` on `html` and `body` leaves the browser nothing to scroll, so Space, Page Down, the arrow keys, Home and End move nothing.
- Only the wheel and a swipe call `show()`. No key does.
- Tab goes straight to "Order now", past the two sections with nothing to focus.
- When it gets there, the browser scrolls the locked `body` to show the link, and the script's count of sections no longer matches what is on screen.

## After: the browser scrolls, and the sections settle into place

```html
<main class="screens">
  <section>
    <h1>Aster One</h1>
    <p>Headphones that fold flat.</p>
  </section>
  <section>
    <h2>Thirty hours on one charge</h2>
    <p>Ten minutes of charging gives you three more.</p>
  </section>
  <section>
    <h2>Made to be mended</h2>
    <p>Every part can be replaced, and the repair guide is free.</p>
  </section>
  <section>
    <h2>$199</h2>
    <p>Free delivery and 30 days to return them.</p>
    <a href="/order">Order now</a>
  </section>
</main>
```

```css
html {
  scroll-snap-type: y proximity;
}
.screens section {
  box-sizing: border-box;
  min-height: 100vh;
  padding: 2rem;
  scroll-snap-align: start;
}
```

1. Remove `overflow: hidden` and the script. The page scrolls again, so Space, Page Down, the arrow keys, Home and End all move it, and Tab brings each link into view, with nothing to write.
2. Put `scroll-snap-type: y proximity` on `html` and `scroll-snap-align: start` on each section. Space and Page Down now land on the next section, as a turn of the wheel did.
3. Change `height` to `min-height`, so a section that needs more room at high zoom or on a small screen grows, and the keys scroll through it.

This fixes the Before and passes 2.1.1 with no script at all. The sections can still animate as they arrive, with an animation tied to the scroll position, which leaves the keys to the browser.

## After: the script stays, and takes the keys and focus

```html
<main class="screens">
  <section>
    <h1>Aster One</h1>
    <p>Headphones that fold flat.</p>
  </section>
  <section>
    <h2>Thirty hours on one charge</h2>
    <p>Ten minutes of charging gives you three more.</p>
  </section>
  <section>
    <h2>Made to be mended</h2>
    <p>Every part can be replaced, and the repair guide is free.</p>
  </section>
  <section>
    <h2>$199</h2>
    <p>Free delivery and 30 days to return them.</p>
    <a href="/order">Order now</a>
  </section>
</main>
```

```css
html {
  height: 100%;
  overflow: hidden;
}
body {
  height: 100%;
  margin: 0;
  overflow: clip;
}
@media (prefers-reduced-motion: no-preference) {
  .screens {
    transition: transform 0.6s ease;
  }
}
.screens section {
  box-sizing: border-box;
  height: 100vh;
  padding: 2rem;
}
```

```js
const screens = document.querySelector('.screens');
let current = 0;
let moving = false;

function show(index) {
  current = Math.min(Math.max(index, 0), screens.children.length - 1);
  screens.style.transform = `translateY(${current * -100}vh)`;
}

window.addEventListener('wheel', (event) => {
  event.preventDefault();
  if (moving) return;
  moving = true;
  setTimeout(() => (moving = false), 700);
  show(current + Math.sign(event.deltaY));
}, { passive: false });
// A swipe calls show() the same way.

const STEPS = { PageDown: 1, ArrowDown: 1, ' ': 1, PageUp: -1, ArrowUp: -1 };

window.addEventListener('keydown', (event) => {
  if (event.altKey || event.ctrlKey || event.metaKey) return;
  if (event.target.closest('input, textarea, select, button, summary, [contenteditable]')) return;
  if (event.key === 'Home') show(0);
  else if (event.key === 'End') show(screens.children.length - 1);
  else if (event.key === ' ' && event.shiftKey) show(current - 1);
  else if (event.key in STEPS) show(current + STEPS[event.key]);
  else return;
  event.preventDefault();
});

screens.addEventListener('focusin', (event) => {
  show([...screens.children].indexOf(event.target.closest('section')));
});
```

1. Add a `keydown` listener that calls the same `show()` as the wheel, for Space, Shift + Space, Page Up, Page Down, the up and down arrows, Home and End.
2. Have it step aside for anything that uses those keys itself, such as a text field, a select or a button, and for combinations such as Ctrl + End.
3. Add a `focusin` listener that shows the section holding the focused control, so Tab and Shift + Tab never leave focus out of view.
4. Change `overflow` on `body` to `clip`, which rules out scrolling of any kind, so the browser can't scroll the page behind the script's back when a link takes focus.

This fixes the Before and passes 2.1.1, on one condition: every section has to fit the window. The script moves one window at a time, so a taller section, at high zoom or on a small screen, is cut off or runs into the next one, and no key scrolls through it. Teams usually keep a script like this for one of these:

- A transition between sections: it can run for the keys as it does for the wheel, as long as `show()` is what starts it, and it needs to be kept inside `prefers-reduced-motion: no-preference`.
- Wrapping round from the last section to the first: the keys can do it too, but Home and End must still go to the two ends.
- A library's own handling: turn on its keyboard option rather than writing this listener, and check Space, Home and End by hand, since an option may not cover them.

## After: a real button for every section

```html
<main class="screens">
  <section>
    <h1>Aster One</h1>
    <p>Headphones that fold flat.</p>
  </section>
  <section>
    <h2>Thirty hours on one charge</h2>
    <p>Ten minutes of charging gives you three more.</p>
  </section>
  <section>
    <h2>Made to be mended</h2>
    <p>Every part can be replaced, and the repair guide is free.</p>
  </section>
  <section>
    <h2>$199</h2>
    <p>Free delivery and 30 days to return them.</p>
    <a href="/order">Order now</a>
  </section>
</main>
<nav class="screen-nav" aria-label="Screens">
  <button type="button" aria-current="true">Aster One</button>
  <button type="button">Thirty hours</button>
  <button type="button">Made to be mended</button>
  <button type="button">$199</button>
</nav>
```

```css
html {
  height: 100%;
  overflow: hidden;
}
body {
  height: 100%;
  margin: 0;
  overflow: clip;
}
@media (prefers-reduced-motion: no-preference) {
  .screens {
    transition: transform 0.6s ease;
  }
}
.screens section {
  box-sizing: border-box;
  height: 100vh;
  padding: 2rem;
}
.screen-nav {
  position: fixed;
  inset: auto 1rem 1rem;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem;
}
```

```js
const screens = document.querySelector('.screens');
const buttons = [...document.querySelectorAll('.screen-nav button')];
let current = 0;
let moving = false;

function show(index) {
  current = Math.min(Math.max(index, 0), screens.children.length - 1);
  screens.style.transform = `translateY(${current * -100}vh)`;
  buttons.forEach((button, i) => {
    if (i === current) button.setAttribute('aria-current', 'true');
    else button.removeAttribute('aria-current');
  });
}

window.addEventListener('wheel', (event) => {
  event.preventDefault();
  if (moving) return;
  moving = true;
  setTimeout(() => (moving = false), 700);
  show(current + Math.sign(event.deltaY));
}, { passive: false });
// A swipe calls show() the same way.

buttons.forEach((button, i) => {
  button.addEventListener('click', () => show(i));
});

screens.addEventListener('focusin', (event) => {
  show([...screens.children].indexOf(event.target.closest('section')));
});
```

1. Add a `nav` with a `button` for each section, each calling `show()`. Tab reaches every button, and Enter or Space shows its section.
2. Mark the button of the section on show with `aria-current`, and move it as the section changes, so its state can be read as well as seen.
3. Add the `focusin` listener and `overflow: clip` from the previous fix, so Tab to "Order now" still shows its section.

This fixes the Before as far as 2.1.1 goes: every section can be reached from the keyboard. Space, Page Down and the arrow keys still move nothing, so a person pressing them has to notice the buttons instead. A section taller than the window has the same problem as in the previous fix. Many of these pages already draw dots for the sections, so this can be the smallest change: if you keep dots, each one needs a name and a visible focus ring, and it should be a `button`.

## Which option to use

All three pass 2.1.1, and only the first passes with no conditions, which is why it is the one to choose. The browser's own scrolling takes every scrolling key, Tab and touch, with no script to keep working, and the sections can grow when someone zooms. What it gives up is exact control: a page can come to rest between two sections, and the movement is the browser's own, not a timed slide. Keep the script only if that slide is the point of the page, and then add the keys, as the second fix does. Treat the buttons as the quick repair for a page you can't rebuild yet, or as an addition to either of the others.

## Implementation decisions

Most of the scroll libraries checked here don't have this failure, so check yours before rewriting anything. With default settings, in Chromium, Firefox and WebKit in November 2026:

- fullPage.js 4: `keyboardScrolling` is on by default. Space, Page Down, the arrow keys, Home and End change the section, and a section taller than the window scrolls first. Don't turn it off.
- Lenis 1.3, Locomotive Scroll 5 and GSAP's ScrollSmoother: they ease the wheel and leave the keys to the browser, so the keys work. Locomotive Scroll 4 in its smooth mode handles the keys itself, and they moved the page in all three.
- GSAP's Observer: it listens to wheel, touch and pointer events and no keys, so a page built on it, like the section demo in its documentation, is this guide's Before. That demo also hides the other sections with `visibility: hidden`, so Tab can't reach them either.
- Swiper 14 with `direction: 'vertical'` and `mousewheel: true`: its keyboard option is off by default, so no key moves it. `keyboard: { enabled: true }` adds the arrow keys, Page Up and Page Down, and leaves out Space, Home and End. With clickable pagination, its bullets are Tab stops that Enter activates only where its A11y module is loaded: the full bundle has it, and a build from separate modules has to add it.

A pinned section can fail the same way. Pinning by itself is fine: a section held in place by `position: sticky` in a tall wrapper, or by GSAP's ScrollTrigger with `pin` and `scrub`, follows the page's scroll position, so Page Down moves through its steps. It fails when a script steps through the section on wheel events and forces every other scroll back to where the page was frozen, a pattern answers on GSAP's forum use to stop momentum scrolling. Recreated and run in all three engines, the page scrolled down to the section, then no key moved it in either direction, and only the wheel got past. Choose the step from the scroll position instead.

Scroll snapping is not the cause. A section taller than the window can still be read with `scroll-snap-type: y mandatory`, since the CSS specification lets the page scroll freely inside a snap area larger than the window, and the arrow keys did so in all three engines. Page Down and Space did too in Chromium and WebKit, but in Firefox they jumped from the top of a tall section to the next one, so use `proximity`, which walked through it in all three. With `mandatory`, don't snap to a heading inside a long section: the specification warns that the content in between can become inaccessible, and in a test in all three engines neither the keys nor the wheel could stop in it.

**Show that the page goes on.** No success criterion asks for this, so it isn't part of passing 2.1.1, but a section that fills the window looks complete. Nielsen Norman Group calls this a "false floor", and says large hero graphics "offer no additional cues to invite users to scroll". Its advice is to let the next content show at the bottom of the screen, which a section a little shorter than the window does. A scrollbar helps too, though some systems hide it until scrolling starts. A down arrow does the same job if it can be seen before anyone scrolls. Make it a real link or button to the next section, so it gives a keyboard a way on as well. With the section buttons fix, the buttons already show there is more.

## Verify the fix

1. Put the mouse aside and load the page. Press Space, Page Down, the down arrow, Page Up, Home and End: each brings another section into view. With the buttons fix, the keys stay dead, so press Tab to each button and Enter instead: each shows its section.
2. Press Tab to "Order now" and Shift + Tab back, in Safari with "Press Tab to highlight each item on a webpage" turned on: the section holding focus is on screen each time, and a turn of the wheel afterwards moves on from that section, not from an old one.
3. Make the window short, or zoom the page to 200%, and bring the end of the longest section into view from the keyboard. With either fix that keeps the script, this is the condition it passes on: if the text is cut off, the fix isn't enough for this page.
4. With a screen reader, read from the top: each section's heading and text are read in order, and "Order now" is reached.
5. Load the page and look at the first screen before touching anything. Something shows that the page goes on: the next section showing at the bottom, a scrollbar that stays visible, or an arrow. No success criterion asks for this, so record it apart from 2.1.1.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after changing the page's sections or updating the library that moves them.

## Limits

This is a starting pattern, not a guarantee for every application. It covers a page whose own scrolling a script has taken over. A page-sized box with `overflow-y: scroll` is a different failure: its keys do nothing until focus is inside it, as in [scrollable areas that a keyboard can't scroll](/learn/scrollable-regions). A long list that loads more as it scrolls is in [infinite scroll that moves the footer away as Tab nears it](/learn/infinite-scroll), motion that can't be turned off in [animations that ignore the reduced-motion setting](/learn/reduced-motion), and a focused control under a bar in [sticky headers and banners that hide keyboard focus](/learn/focus-not-obscured). A link that takes focus out of view while a script holds the page still can't be seen either, as in [missing focus indicators on custom buttons](/learn/visible-focus); fixing the scrolling fixes that too. The libraries were tested with a keyboard in desktop builds of Chromium, Firefox and WebKit, not in Safari itself, on a phone or with a screen reader.

---

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