Keyboard Access to Scroll-Driven Interfaces
Scroll-driven effects are driven by scrolling, and scrolling is something keyboards do perfectly well β provided the thing being scrolled is reachable. The failures in this area are rarely about the animation and almost always about the container: a panel that only scrolls with a pointer, a gallery with no accessible name, a header that hides while its links stay focusable. This guide covers each. It sits under accessible transitions.
When to use this approach
- Any component with its own scroll container. The document scrolls with the keyboard by default; a nested container does not until it can receive focus.
- Horizontal galleries and carousels. These are the most common place for content to be entirely unreachable without a pointer.
- Sticky headers that hide. Anything moved off-screen with a transform keeps its focusable children in the tab order.
- Pinned or stacked sections. The visual stacking must not imply that covered content is unreachable, because it is not.
Implementation
1. Make nested scroll containers focusable
<div class="log" tabindex="0" role="region" aria-label="Deployment log">
β¦
</div>
A scrollable element with tabindex="0" can be focused and then scrolled with the arrow keys, Page Up and Page Down. Without it, a keyboard user has no way to reach content below the fold of that container β the page scrolls past it instead.
Browsers increasingly make scrollable regions keyboard-focusable automatically, but the behaviour is not universal across engines and versions, so the explicit attribute is still the reliable form.
2. Give the region a name
An unlabelled focusable region announces as βregionβ and nothing else. A short aria-label β or an aria-labelledby pointing at a nearby heading β tells the reader what they have arrived at before they start scrolling it.
3. Expect focus to drive the timeline
This is correct behaviour rather than something to prevent: the reader is moving through the content and the animation is following. What it does mean is that keyboard navigation is a genuine input path for your effects, and one that is never exercised by pointer testing.
/* make the movement legible rather than instantaneous */
.gallery { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
.gallery { scroll-behavior: auto; }
}
4. Stop hidden headers trapping focus
.site-header {
transition: translate 200ms ease;
}
.site-header.is-hidden { translate: 0 -100%; }
.site-header.is-hidden:focus-within { translate: 0 0; }
Bringing the header back when anything inside it receives focus is one declaration and removes the trap entirely. The alternative β removing the links from the tab order while hidden β also works and is more code.
5. Keep pinned content in document order
A stacked-card section produces its visual order through sticky positioning and transforms, neither of which changes the DOM. Tab order therefore follows the markup, which is what you want β but it is worth confirming, because a implementation that reorders cards with order or absolute positioning will not.
Verification
Unplug the mouse, metaphorically or otherwise, and use each animated section. This single exercise finds more than any checklist: a container you cannot scroll, a gallery whose later items are unreachable, a header that swallows focus.
Tab from the element before each animated section to the element after it, watching the focus ring the whole way. Every stop should be visible; a stop where nothing appears to have focus is either a hidden focusable element or a focus style that has been removed.
Scroll a nested container with the arrow keys after focusing it, and confirm any timeline bound to it advances as expected. If the animation only responds to the wheel, the container is being scrolled by the page rather than by itself.
Finally, check the reduced-motion path. Smooth scroll behaviour is motion, and a reader who has asked for less of it should get instant scroll positioning rather than a glide.
Edge cases and gotchas
tabindex="0" on a container with focusable children. The container itself becomes a tab stop before its children, which adds one stop. That is acceptable and expected for a scrollable region; it becomes annoying when applied to containers that are not scrollable, so apply it only where it is needed.
Removed focus styles. A design that suppresses outlines globally makes every keyboard path invisible. :focus-visible gives the control most designs actually want without removing the affordance.
Scroll-snap and keyboard scrolling. Arrow-key scrolling in a mandatory-snap container jumps a whole snap point at a time, which can make fine positioning impossible. Proximity snapping restores the ability to scroll by increments.
Focus inside a content-visibility: auto section. Focusing an element in a skipped section forces it to render, which is correct β but any animation in that section starts from whatever progress the scroll position implies, which can look like a jump.
Sticky headers overlapping the focused element. A focused element scrolled to the top of the viewport can end up underneath a sticky header. scroll-margin-block-start on focusable elements, set to the header height, fixes it.
Browser-specific notes
Keyboard scrolling of a focused container behaves consistently across engines. Where they differ is in whether a scrollable container is focusable without an explicit tabindex.
Chromium has shipped automatic focusability for keyboard-scrollable regions that contain no focusable children, which covers the common case of a scrollable log or code block. Firefox has similar behaviour. Safari is the least consistent of the three, which is the practical reason to keep writing the attribute explicitly rather than relying on it.
The accessibility checkers built into each engineβs developer tools all flag a scrollable region without a name, and all three are worth running once per component β this is one of the rare accessibility issues that automated tooling catches reliably.
Frequently Asked Questions
Does adding tabindex="0" hurt anything?
It adds one tab stop per container, which is a small cost against making the content reachable at all. The mistake to avoid is applying it to elements that are not scrollable and not interactive, where it adds stops that do nothing. Scrollable regions are exactly the case it exists for.
Should a gallery have previous and next buttons?
If the scrollbar is hidden, yes. A horizontally scrollable region with no visible scrollbar and no controls is discoverable only by trying, which is a poor experience for everyone and an impossible one for some. Buttons also give keyboard users a way to move by item rather than by pixel.
How does focus interact with a pinned stack?
Focus moves through the cards in document order and the browser scrolls each into view, which unpins the previous card and pins the next β exactly as scrolling would. The thing to verify is that focusing a card that is currently covered brings it forward rather than leaving focus on something the reader cannot see.
Is scroll-behavior: smooth an accessibility problem?
It is motion, so it belongs under the reduced-motion preference like any other. It is generally helpful otherwise, because an instantaneous jump on focus can be more disorienting than a short glide β which is why the recommendation is to use it and override it, rather than to avoid it.
What about a pinned section that consumes several viewports?
Keyboard scrolling moves in fixed increments, so a section designed around several viewports of scroll takes a great many key presses to get past. That is a usability problem rather than a correctness one, and it lands hardest on the people least able to work around it.
The mitigation is the same one that helps everyone: shorter per-step distances, so the section is passable at a reasonable rate. Where the section genuinely must be long, a skip link that jumps to the content after it is a small addition that changes the experience considerably.
Related
- Horizontal scroll gallery with a scroll timeline β the component that needs this most
- Animating sticky headers on scroll direction change β where the hidden-focusable trap appears
- Screen-reader testing for animated pages β the complementary pass
- Scroll timelines on nested scroll containers β the containers this guide is about