Accessibility Fundamentals for Engineers: Semantics, Focus, and Reduced Motion
Accessibility is often described as a design concern, but the failures users feel most acutely are usually implementation failures. A button that is actually a div, a modal that traps focus incorrectly, or an animation-heavy interface that ignores reduced-motion preferences are engineering decisions. If the code gets those details wrong, the interface is hostile no matter how polished it looks.
The practical way to think about accessibility is simple: start with correct semantics, preserve keyboard access, manage focus deliberately, and respect user preferences that affect comfort and orientation. Reduced motion belongs in that list. It is not a nice-to-have. For some users, excessive motion is distracting. For others, it is physically uncomfortable.
Start With Semantic HTML
The cheapest accessibility win is using the correct element in the first place. Browsers already know how buttons, links, form controls, headings, and lists are supposed to behave. When you use the real element, you get keyboard behavior, focusability, and accessibility semantics for free.
<!-- Poor: clickable but not announced as a button -->
<div class="save-button" onclick="saveForm()">Save</div>
<!-- Better: keyboard support and semantics are built in -->
<button type="button" class="save-button" onclick="saveForm()">Save</button>
That one change fixes several problems at once:
- screen readers announce the control as a button
- keyboard users can reach it with Tab
- Enter and Space activate it correctly
- focus states work without extra scripting
The same rule applies across the page:
- use
afor navigation - use
buttonfor actions - use headings in order so the page has structure
- use
labelelements for form controls - use lists for grouped repeated content
If you reach for div first, you are usually signing up to rebuild behavior the platform already had.
Use ARIA to Fill Gaps, Not Replace HTML
ARIA is useful when native HTML semantics are not enough, but it should not be your first move. Native elements are more reliable because browsers and assistive technologies already understand them deeply.
For example, a disclosure button that opens extra content can be implemented with a real button and a couple of ARIA attributes:
<button type="button" aria-expanded="false" aria-controls="faq-answer">
What does your API rate limit mean?
</button>
<div id="faq-answer" hidden>
The limit is enforced per access token over a rolling 60-second window.
</div>
When the panel opens, your script updates aria-expanded to true and removes hidden from the target. That is a good ARIA use: it communicates state that HTML alone does not fully express.
Bad ARIA usually looks like this:
<div role="button" tabindex="0">Save</div>
Now you have to recreate keyboard activation, disabled state, focus styling, and event handling manually. The first rule of ARIA remains correct: no ARIA is better than bad ARIA.
Keyboard Access Is a Functional Requirement
If a component only works with a mouse or trackpad, it is incomplete. Keyboard users need to be able to move through the interface predictably and trigger every important action.
For custom widgets, this usually means implementing the keyboard behavior users already expect. A modal example is a good illustration:
import { useEffect, useRef } from 'react'
export function ConfirmDialog({
isOpen,
onClose,
}: {
isOpen: boolean
onClose: () => void
}) {
const dialogRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!isOpen) return
const previouslyFocused = document.activeElement as HTMLElement | null
dialogRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
previouslyFocused?.focus()
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div role="presentation" className="backdrop">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
tabIndex={-1}
>
<h2 id="confirm-title">Delete article?</h2>
<p>This action cannot be undone.</p>
<button type="button" onClick={onClose}>
Cancel
</button>
<button type="button">Delete</button>
</div>
</div>
)
}
This example does three things that matter:
- moves focus into the dialog when it opens
- lets Escape close it
- returns focus to the trigger when it closes
That behavior is not decorative. It is how keyboard and screen-reader users stay oriented.
Focus Management Is Part of State Management
Frontend engineers already manage visual state carefully. Focus state deserves the same discipline.
Whenever the UI changes context, ask two questions:
- Where should focus go now?
- How does the user get back to where they came from?
Common places where this matters:
- modals and drawers
- dropdown menus
- client-side route transitions
- inline validation errors
- toast or banner content that interrupts the normal flow
If a form submit fails, sending focus to the first invalid field or to an error summary is often the difference between a recoverable flow and a confusing one.
if (!email) {
setError('Email is required')
emailInputRef.current?.focus()
return
}
That is accessibility work. It is also just good product engineering.
Reduced Motion Is an Accessibility Requirement
Reduced motion gets missed because teams tend to think about accessibility only in terms of screen readers or keyboard use. But motion sensitivity is real, and modern interfaces are full of animated transitions, parallax effects, autoplaying carousels, and scroll-linked movement.
Users who enable reduced motion at the operating-system level are telling the browser they want less non-essential animation. Your job is to listen.
The browser exposes that preference through the prefers-reduced-motion media query:
.card {
transition:
transform 250ms ease,
box-shadow 250ms ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 30px rgb(0 0 0 / 0.12);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: box-shadow 120ms linear;
}
.card:hover {
transform: none;
}
}
The goal is not necessarily to eliminate every transition. The goal is to remove or simplify motion that is non-essential to understanding the interface. In that example, the hover feedback still exists, but the movement is gone.
For large-scale motion, the safest pattern is usually to disable it entirely:
.hero-illustration {
animation: float 6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.hero-illustration {
animation: none;
}
}
That matters for:
- parallax sections
- looping decorative animations
- carousels that auto-advance
- smooth scrolling triggered by script
- page transitions that slide or zoom large regions of the screen
If the animation is essential, keep it short, subtle, and purposeful. If it is decorative, it should almost always be the first thing you cut.
Respect Reduced Motion in JavaScript Too
Not all motion is CSS-driven. Some of the most problematic motion comes from JavaScript behavior: autoplaying a slider, animating scroll position, or sequencing elements into view on page load.
In React, you can read the preference once and branch your behavior accordingly:
import { useEffect, useState } from 'react'
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
function handleChange() {
setPrefersReducedMotion(mediaQuery.matches)
}
handleChange()
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return prefersReducedMotion
}
export function TestimonialCarousel() {
const prefersReducedMotion = usePrefersReducedMotion()
useEffect(() => {
if (prefersReducedMotion) return
const timer = window.setInterval(() => {
advanceSlide()
}, 5000)
return () => window.clearInterval(timer)
}, [prefersReducedMotion])
return <Carousel autoPlay={!prefersReducedMotion} />
}
The important part is not the hook itself. It is the policy behind it:
- do not autoplay when the user asked for reduced motion
- do not animate scroll position unless it is necessary
- do not gate comprehension behind motion
- make state changes visible even when animation is removed
That last point is critical. If a drawer only feels like it opened because it slides in, removing motion can make the state change ambiguous. You still need strong static cues: contrast, placement, headings, overlays, and clear labels.
A Practical Accessibility Review Checklist
For most interface work, these checks catch the bulk of implementation-level issues early:
- Is every interactive thing built with the correct element?
- Can the full flow be completed with a keyboard?
- Is focus visible, and does it move intentionally when the UI changes?
- Are ARIA attributes used to communicate real state rather than to patch over incorrect markup?
- Does the interface respect
prefers-reduced-motionfor non-essential motion? - If motion is removed, is the UI still clear and understandable?
That checklist is small on purpose. Accessibility work becomes durable when it is embedded in everyday engineering decisions, not deferred to a final audit.
Conclusion
Accessibility fundamentals are not abstract guidelines. They are implementation details that either exist in the code or they do not. Semantic HTML gives you the right base. Keyboard support and focus management preserve usability. Reduced-motion support respects a user preference that can determine whether your interface feels comfortable or unusable.
If you treat those concerns as part of the component contract from the start, accessibility stops being a scramble at the end of the sprint and becomes what it should be: normal engineering work.
