CSS, layout & accessibility

Accessible components in Vue and Nuxt

Building the widgets that go wrong most — modal dialogs, comboboxes, tabs, menus, toasts, forms — with Reka UI and Nuxt UI or by hand following the ARIA Authoring Practices, plus route announcements, focus on navigation, reduced motion, and testing with role-based locators.

"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.

Know

  • Use a primitive library unless you cannot. Reka UI (the headless base of Nuxt UI 4) implements the APG patterns for Dialog, Popover, Combobox, Select, Tabs, Menu, Accordion, Tooltip, Toast and more: keyboard navigation, focus management, ARIA wiring, portal and dismiss behaviour, without styles. Nuxt UI styles them. Building these by hand is justified for learning or when a pattern is missing.
  • Modal dialog: 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.
  • Combobox / autocomplete: the input has 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.
  • Tabs: 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.
  • Menus and disclosure: a <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>.
  • Toasts and status: 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.
  • Routing: announce the new page (<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.
  • Forms in components: 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.
  • Motion: usePreferredReducedMotion() (VueUse) or @media (prefers-reduced-motion) to disable transitions; experimental.viewTransition: true in Nuxt already skips users who prefer reduced motion.
  • Testing: Playwright and Testing Library role-based locators (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.

How it works

An accessible modal by hand, showing every behaviour the primitive libraries encapsulate:

app/components/A11y/Modal.vue
<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:

app/components/A11y/RekaModal.vue
<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:

app/components/A11y/Tabs.vue
<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>
test/e2e/modal.spec.ts
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
})
Gotcha· Teleport and inert

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.

Gotcha· aria-selected on the wrong element

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.

Verify before the interview:

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 ↗

Exercise

Exercise
  • Build the hand-rolled modal, run the Playwright test, then break one behaviour at a time (remove inert, skip focus restore) and watch the test catch it.
  • Replace it with the Reka UI version and diff the behaviour with VoiceOver: what is announced on open, on Escape, on close?
  • Build the tabs and verify roving tabindex with the keyboard; then add automatic activation on arrow keys and decide which you prefer for a settings page.

Be able to say

Be able to say· Build me an accessible modal. What does it need?

"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."

Be able to say· How do you handle accessibility when navigating in a Nuxt app?

"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."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.