"Build an accessible modal" is a classic live-coding task because it tests six things at once: semantics, focus trapping, focus restoration, Escape handling, inert background and announcing. The senior answer starts with "I would use <dialog> or a headless primitive" and continues with exactly what those do, because the interviewer wants to know that you could build it if you had to.
role="dialog" (or native <dialog> with showModal()), aria-modal="true", aria-labelledby pointing at the title, focus moves inside on open (first focusable or the title), Tab is trapped inside, Escape closes, background is inert (native <dialog> does this) and scroll-locked, focus returns to the trigger on close, content is teleported to body for stacking. In Vue: useTemplateRef + nextTick() to focus after render, useFocusTrap from VueUse integrations, or Reka UI's DialogRoot.role="combobox", aria-expanded, aria-controls pointing at a role="listbox", aria-activedescendant for the highlighted option (focus stays in the input), options are role="option" with aria-selected; arrows move, Enter selects, Escape closes, typing filters; results count announced through a live region; debounced fetch with cancellation.role="tablist", role="tab" with aria-selected and aria-controls, role="tabpanel" with aria-labelledby; roving tabindex (only the active tab is in the tab order; arrows move between tabs; Home/End), automatic or manual activation.<button aria-expanded aria-controls> toggling content is a disclosure and needs no menu roles; real role="menu"/menuitem is for application-style menus with arrow navigation and typeahead; navigation lists are <nav><ul>.role="status" (polite) for confirmations, role="alert" (assertive) for errors; the live region exists in the DOM before the message is inserted; Nuxt's <NuxtAnnouncer> + useAnnouncer() provide it; toasts need a pause-on-hover and a way to dismiss with the keyboard, and important ones must persist.<NuxtRouteAnnouncer> reads the title), set a title per route (useHead/useSeoMeta), move focus to the main content or the heading after navigation (skip link target, tabindex="-1" on <main>), restore scroll sensibly and respect reduced motion for page transitions.useId() for every association so SSR and client agree; custom field components accept label, hint, error and wire aria-describedby/aria-invalid; defineModel for the value; Nuxt UI's UFormField does this.usePreferredReducedMotion() (VueUse) or @media (prefers-reduced-motion) to disable transitions; experimental.viewTransition: true in Nuxt already skips users who prefer reduced motion.getByRole('dialog', { name }), getByRole('option')) fail when semantics are missing, which makes them accessibility tests; @axe-core/playwright for rules; toBeFocused() assertions for focus management; vitest-axe for component tests with mountSuspended.An accessible modal by hand, showing every behaviour the primitive libraries encapsulate:
<script setup lang="ts">
const open = defineModel<boolean>({ default: false })
const { title } = defineProps<{ title: string }>()
const id = useId()
const panel = useTemplateRef<HTMLDivElement>('panel')
let opener: HTMLElement | null = null
const focusable = () => [...panel.value!.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])')]
watch(open, async (isOpen) => {
if (isOpen) {
opener = document.activeElement as HTMLElement | null // remember where focus came from
await nextTick()
document.body.style.overflow = 'hidden'
document.getElementById('__nuxt')?.setAttribute('inert', '') // background not focusable or clickable
;(focusable()[0] ?? panel.value)?.focus()
}
else {
document.body.style.overflow = ''
document.getElementById('__nuxt')?.removeAttribute('inert')
opener?.focus() // restore focus
}
})
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { e.preventDefault(); open.value = false; return }
if (e.key !== 'Tab') return
const items = focusable()
if (!items.length) return
const first = items[0]!, last = items.at(-1)!
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() } // trap
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 grid place-items-center bg-black/50" @click.self="open = false">
<div
ref="panel"
role="dialog"
aria-modal="true"
:aria-labelledby="`${id}-title`"
tabindex="-1"
class="rounded-lg bg-default p-6 shadow-xl"
@keydown="onKeydown"
>
<h2 :id="`${id}-title`" class="text-lg font-semibold">{{ title }}</h2>
<slot />
<button type="button" class="mt-4" @click="open = false">Close</button>
</div>
</div>
</Teleport>
</template>
The same widget with Reka UI, which is what you ship:
<script setup lang="ts">
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger } from 'reka-ui'
const open = defineModel<boolean>({ default: false })
</script>
<template>
<DialogRoot v-model:open="open">
<DialogTrigger as-child><slot name="trigger" /></DialogTrigger>
<DialogPortal>
<DialogOverlay class="fixed inset-0 bg-black/50" />
<DialogContent class="fixed inset-0 m-auto h-fit max-w-md rounded-lg bg-default p-6">
<DialogTitle class="text-lg font-semibold"><slot name="title" /></DialogTitle>
<DialogDescription class="sr-only"><slot name="description" /></DialogDescription>
<slot />
<DialogClose class="mt-4">Close</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
Tabs with roving tabindex, and a Playwright test that doubles as an accessibility check:
<script setup lang="ts">
const { tabs } = defineProps<{ tabs: { id: string, label: string }[] }>()
const active = defineModel<string>({ default: '' })
const id = useId()
function onKeydown(e: KeyboardEvent, index: number) {
const map: Record<string, number> = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: tabs.length - 1 }
const next = map[e.key]
if (next === undefined) return
e.preventDefault()
const target = tabs[(next + tabs.length) % tabs.length]!
active.value = target.id
document.getElementById(`${id}-tab-${target.id}`)?.focus()
}
</script>
<template>
<div role="tablist" aria-label="Sections">
<button
v-for="(tab, i) in tabs"
:id="`${id}-tab-${tab.id}`"
:key="tab.id"
role="tab"
type="button"
:aria-selected="tab.id === active"
:aria-controls="`${id}-panel-${tab.id}`"
:tabindex="tab.id === active ? 0 : -1"
@click="active = tab.id"
@keydown="onKeydown($event, i)"
>{{ tab.label }}</button>
</div>
<div v-for="tab in tabs" v-show="tab.id === active" :id="`${id}-panel-${tab.id}`" :key="tab.id" role="tabpanel" :aria-labelledby="`${id}-tab-${tab.id}`" tabindex="0">
<slot :name="tab.id" />
</div>
</template>
import { expect, test } from '@nuxt/test-utils/playwright'
test('modal traps and restores focus', async ({ page, goto }) => {
await goto('/demo/modal', { waitUntil: 'hydration' })
const trigger = page.getByRole('button', { name: 'Open settings' })
await trigger.click()
const dialog = page.getByRole('dialog', { name: 'Settings' }) // fails if aria-labelledby is missing
await expect(dialog).toBeVisible()
await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Tab')
await expect(dialog.locator(':focus')).toBeVisible() // focus never left the dialog
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
await expect(trigger).toBeFocused() // restored
})
Setting inert on #__nuxt while the dialog is teleported to body works because the dialog is outside that subtree; teleporting inside the app root would make the dialog itself inert. Native <dialog showModal()> avoids the whole question by putting the dialog in the top layer and making everything else inert automatically.
Tabs mark the role="tab" button as selected, comboboxes mark role="option", never the panel or the input. Mixed-up attributes make screen readers announce nonsense, and axe does not always catch it; the APG pattern page is the reference.
Reka UI's current component list and the Nuxt UI 4 mapping onto it; also whether Nuxt's accessibility best-practices page recommends specific focus behaviour on navigation.
docs ↗inert, skip focus restore) and watch the test catch it."Semantics: role=\"dialog\" with aria-modal and aria-labelledby pointing at the title, or the native <dialog> opened with showModal(), which gives most of the rest for free. Focus: move it inside on open, keep Tab cycling within the dialog, and return it to the element that opened the dialog on close. Dismissal: Escape closes, clicking the backdrop optionally closes, and the background is inert and scroll-locked so nothing behind is reachable. Rendering: teleport to body so stacking works, and animate with respect for reduced motion. In practice I use Reka UI's Dialog, which Nuxt UI wraps, and I test it with role-based Playwright locators that fail when the name or the focus behaviour is wrong; but I can build it by hand, and that is the version I would sketch in an interview."
"A single-page navigation does not reload, so assistive technology has to be told: <NuxtRouteAnnouncer> announces the new page title, which I set per route with useSeoMeta. Focus should move to a sensible place, the main region or the page heading, via a skip-link target with tabindex=\"-1\", so keyboard users do not continue from a link that no longer exists. Scroll restoration follows the router's behaviour, and page or view transitions are disabled when the user prefers reduced motion, which Nuxt's experimental.viewTransition respects by default. For in-page changes, form results, toasts, loading states, useAnnouncer with a polite or assertive live region does the announcing."
Nothing yet. Notes filed here show up on this page and the section overview.
Accessibility fundamentals
WCAG 2.2 and its new criteria, the European Accessibility Act, semantic HTML first, the rules of ARIA, keyboard and focus management, forms and errors, colour and motion, screen reader testing, and the tooling that makes accessibility verifiable.
Browser, HTTP & networking
The platform underneath every framework — how browsers turn bytes into pixels, how HTTP and caching work, what fetch, CORS and cookies actually do, what browser storage and service workers offer, and which web APIs a senior should know by name.