Inertia gives Laravel and Vue the speed of client-side navigation without forcing you into a separate API architecture. That makes it a strong fit for a php web framework application.
It also changes the accessibility contract.
A traditional browser navigation reloads the document. The browser resets focus, announces the new document, and reads the new title. An Inertia visit replaces page content without that full reload.
The result is easy to miss:
- Focus remains on the link or button that started the visit.
- Screen readers may not announce the new page.
- The document title can remain stale.
- Users may lose their place after partial reloads.
- New validation errors can appear without being announced.
Inertia 3.x provides the lifecycle hooks and primitives needed to close these gaps. You still need to connect them to accessible behavior.
Why SPA navigation breaks normal browser behavior
A single-page application keeps the document alive while its content changes. The browser sees one document, even when the user sees a new page.
That distinction matters for keyboard and screen reader users. After clicking a “Reports” link, focus may stay on that link in the old navigation. A screen reader user then has to search for the new content manually.
The first fix is simple: give every page a clear heading and move focus to it after a successful Inertia visit.
Use the navigate event. Inertia documents this event as firing after successful page visits and history navigation.
// resources/js/accessibility.ts
import { nextTick } from 'vue'
import { router } from '@inertiajs/vue3'
let ready = false
router.on('navigate', async (event) => {
// Do not steal focus during the initial document load.
if (!ready) {
ready = true
return
}
await nextTick()
const heading = document.querySelector<HTMLElement>(
'main h1[tabindex="-1"]',
)
const main = document.querySelector<HTMLElement>('#main-content')
// Focus the page heading first, then fall back to the main landmark.
const target = heading ?? main
target?.focus({ preventScroll: true })
announce(`Page loaded: ${document.title}`)
})
Register this once from your application bootstrap. The nextTick() call gives Vue time to render the new page component.
Your page layout can provide the target:
<script setup lang="ts">
import { Head } from '@inertiajs/vue3'
</script>
<template>
<Head title="Reports" />
<main
id="main-content"
aria-labelledby="page-title"
tabindex="-1"
>
<h1 id="page-title" tabindex="-1">
Reports
</h1>
<slot />
</main>
</template>
The tabindex="-1" value makes the heading focusable by script. It does not add the heading to the normal Tab order.
<Head title="Reports" /> updates the document title during the visit. That title gives screen readers another useful page boundary.
Read more in the Inertia events documentation and title and meta documentation.
Announce route changes politely
Focus helps users understand where they are. A live region provides an additional announcement without requiring focus to move.
Mount one live region in your root layout. Keep it outside page components so it survives navigation.
<template>
<div id="app">
<slot />
<div
id="route-live-region"
class="sr-only"
aria-live="polite"
aria-atomic="true"
/>
</div>
</template>
Then update it after navigation:
function announce(message: string) {
const region = document.querySelector<HTMLElement>('#route-live-region')
if (!region) {
return
}
// Clearing first helps repeat announcements reliably.
region.textContent = ''
requestAnimationFrame(() => {
region.textContent = message
})
}
Use polite for route changes and background updates. Avoid announcing every loading state. A screen reader should hear “Page loaded: Reports,” not a stream of internal request events.

Make skip links survive partial reloads
A skip link remains useful in an Inertia application. It lets keyboard users bypass persistent navigation whenever they choose.
<a class="skip-link" href="#main-content">
Skip to content
</a>
<nav aria-label="Primary">
<!-- persistent navigation -->
</nav>
<main id="main-content" tabindex="-1">
<!-- page content -->
</main>
Keep the id="main-content" target stable across layouts and page components. Do not create a new target only after deferred data arrives.
Partial reloads should not move focus unexpectedly. For example, a filter can refresh only its results while preserving the user’s scroll position:
router.reload({
only: ['results'],
preserveScroll: true,
})
The user remains near the filters. The skip link still provides an explicit path to the main content when they need one.
This distinction matters. preserveScroll protects the current position. Focus management should provide a predictable destination. Do not use either behavior as a substitute for the other.
Use Inertia 3.x primitives with accessible states
Several Inertia features already support good accessibility patterns when their UI states are explicit.
Deferred props and rescue slots
A deferred section should communicate that it is loading. A skeleton should also show what is loading. A blank shimmer does not help a screen reader user understand the page.
<script setup lang="ts">
import { Deferred, router } from '@inertiajs/vue3'
</script>
<template>
<section aria-labelledby="metrics-title">
<h2 id="metrics-title">Report metrics</h2>
<Deferred data="metrics">
<template #fallback>
<div
aria-busy="true"
role="status"
class="metrics-skeleton"
>
Loading report metrics: revenue, users, and conversion rate.
</div>
</template>
<template #rescue="{ reloading }">
<div role="alert">
<p>Report metrics could not be loaded.</p>
<button
type="button"
:disabled="reloading"
@click="router.reload({ only: ['metrics'] })"
>
Try again
</button>
</div>
</template>
<MetricsTable />
</Deferred>
</section>
</template>
The fallback has aria-busy="true" and meaningful text. The rescue state explains the failure and provides a keyboard-accessible retry.
The deferred props documentation covers the loading and rescue lifecycle.
Polling and HTTP requests
usePoll and useHttp are useful for background data. They should not turn every response into an assistive technology interruption.
Use partial data updates where possible:
import { usePoll } from '@inertiajs/vue3'
usePoll(5000, {
only: ['status'],
}, {
mode: 'rest',
})
Announce only meaningful state changes. For example, announce “Export ready” once when the status changes from processing to complete. Do not announce every poll tick.
For useHttp, apply the same rule. A request starting or finishing is usually not user-facing information. Announce the result only when it changes what the user can do.
Make useForm errors easy to find
Laravel validation errors arrive through Inertia’s form state. They still need a clear focus target and correct relationships.
Place an error summary before the fields. Link each message to its input. Set aria-invalid and aria-describedby on the invalid field.
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { useForm } from '@inertiajs/vue3'
const summary = ref<HTMLElement | null>(null)
const form = useForm({
name: '',
email: '',
})
function submit() {
form.post('/users', {
onError: async () => {
await nextTick()
summary.value?.focus()
},
})
}
</script>
<template>
<form @submit.prevent="submit" novalidate>
<div
v-if="form.hasErrors"
ref="summary"
tabindex="-1"
role="alert"
aria-labelledby="error-summary-title"
>
<h2 id="error-summary-title">
There were problems with your submission.
</h2>
<ul>
<li v-if="form.errors.name">
<a href="#name">Name: {{ form.errors.name }}</a>
</li>
<li v-if="form.errors.email">
<a href="#email">Email: {{ form.errors.email }}</a>
</li>
</ul>
</div>
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
:aria-invalid="form.errors.name ? 'true' : 'false'"
:aria-describedby="form.errors.name ? 'name-error' : undefined"
/>
<p v-if="form.errors.name" id="name-error">
{{ form.errors.name }}
</p>
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
:aria-invalid="form.errors.email ? 'true' : 'false'"
:aria-describedby="form.errors.email ? 'email-error' : undefined"
/>
<p v-if="form.errors.email" id="email-error">
{{ form.errors.email }}
</p>
<button type="submit" :disabled="form.processing">
Save user
</button>
</form>
</template>
The Inertia forms documentation explains how useForm receives Laravel’s server-side validation errors.
Route-based modals need a complete focus cycle
A modal opened through an Inertia route still behaves like a dialog. Its URL does not make it accessible automatically.
The minimum requirements are:
- Move focus into the dialog.
- Give it
role="dialog"andaria-modal="true". - Label it with a visible heading.
- Trap Tab focus inside the dialog.
- Close it with Escape.
- Return focus to the trigger when it closes.
- Make the background inert while it is open.
<template>
<button ref="trigger" type="button" @click="open">
Edit profile
</button>
<div
v-if="isOpen"
ref="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
@keydown.esc.prevent="close"
@keydown.tab.prevent="cycleFocus"
>
<h2 id="dialog-title">Edit profile</h2>
<!-- Form fields -->
<button type="button" @click="close">
Cancel
</button>
</div>
</template>
Set inert on the application content behind the dialog. Use a tested focus-trap implementation rather than relying on visual styling alone. When the modal closes, verify that the original trigger still exists before restoring focus.

Test focus, titles, and live regions
Accessibility behavior belongs in automated tests. A page can look correct while focus remains lost after navigation.
With Laravel Dusk, assert the title and focused element:
$browser->visit('/dashboard')
->clickLink('Reports')
->waitForLocation('/reports')
->assertTitle('Reports')
->assertFocused('#page-title');
Dusk provides both assertTitle and assertFocused.
Playwright offers similar checks:
await page.getByRole('link', { name: 'Reports' }).click()
await expect(page).toHaveTitle('Reports')
await expect(page.getByRole('heading', { name: 'Reports' }))
.toBeFocused()
await expect(page.locator('#route-live-region'))
.toHaveText('Page loaded: Reports')
Add axe-style audits to CI with a representative page set. Include:
- Initial page loads.
- Inertia navigation.
- Partial reloads.
- Validation failures.
- Deferred loading and rescue states.
- Route-based dialogs.
- Keyboard-only interaction.
Automated audits cannot verify every focus decision. They can catch missing labels, invalid ARIA relationships, contrast failures, and many broken landmark structures before release.

An agency and enterprise shipping checklist
Before shipping an Inertia 3.x application, review each flow with both a keyboard and a screen reader.
- Every page has a unique
<Head>title. - Every page has one clear
h1. - Navigation moves focus to the new page heading.
- The route live region announces meaningful page changes.
- Skip links work after partial reloads.
-
preserveScrolldoes not hide updated content. - Deferred skeletons use
aria-busyand meaningful text. - Rescue states explain failures and expose a retry.
- Polling announces state changes once, not every tick.
- Form summaries receive focus after validation errors.
- Fields use
aria-invalidandaria-describedby. - Modals trap focus, close with Escape, and restore focus.
- Dusk or Playwright checks titles and focus.
- Axe-style audits run in CI.
These practices apply across Laravel applications, from an internal admin panel to a public product. They also belong in the broader set of php developer tools your team uses to ship confidently.
Whether you are building a dashboard, a php web framework product, or planning to build rest api with php, the principle stays the same: client-side navigation should feel fast without making users lose their place.