Focus

Move focus after a navigation, keep it inside a modal, hand it back on close, and keep the ring visible.

Syntaxconst dialog = useTemplateRef<HTMLDialogElement>('dialog')

Focus is the single place keyboard and screen reader input goes. On a server-rendered site the browser handles it: a new document starts at the top. A Vue application replaces content without a new document, so focus stays where it was — usually on the link just clicked, or lost to <body> when that link unmounts. The reader hears nothing about the new page and the next Tab starts from the top of the page.

After a route change

Give the page container tabindex="-1", which makes it focusable by script but not reachable by Tab, and focus it in router.afterEach. Await nextTick() first; when the hook runs, the new view has not rendered yet.

In a modal

A dialog must take focus when it opens, keep it inside while open, and return it to whatever opened it. The native <dialog> element opened with showModal() does the first two, plus Esc to close and making the rest of the page inert. Record document.activeElement before opening so you can focus it again after.

Keep it visible

outline: none with nothing in its place makes an application unusable without a mouse. Style :focus-visible instead: the browser applies it for keyboard focus and not for mouse clicks.

What trips people up

  • Positive tabindex values reorder the whole document. Only 0 and -1 are useful.
  • autofocus is unreliable on an element Vue inserted after load. Call focus() yourself.

The example draws nothing until open becomes true; then a modal appears with focus inside it, Esc closes it, and focus returns to the button that opened it.

Example

Example · vue
<script setup lang="ts">
import { useId, useTemplateRef, watch } from 'vue'

const props = defineProps<{
  open: boolean
  title: string
}>()

const emit = defineEmits<{
  close: []
}>()

const dialog = useTemplateRef<HTMLDialogElement>('dialog')
const titleId = useId()
let opener: HTMLElement | null = null

watch(
  () => props.open,
  (isOpen) => {
    const el = dialog.value
    if (!el) return
    if (isOpen) {
      opener = document.activeElement as HTMLElement | null
      el.showModal()
    } else if (el.open) {
      el.close()
    }
  }
)

function handleClose() {
  emit('close')
  opener?.focus()
  opener = null
}
</script>

<template>
  <dialog ref="dialog" :aria-labelledby="titleId" @close="handleClose">
    <h2 :id="titleId">{{ props.title }}</h2>
    <slot />
    <button type="button" @click="dialog?.close()">Close</button>
  </dialog>
</template>

<style scoped>
dialog::backdrop {
  background: rgb(0 0 0 / 0.5);
}

dialog :focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}
</style>

When to use it

  • Making a single-page app announce the new page after a navigation instead of staying silent.
  • Opening a confirm dialog from a table row and returning focus to that row's button on close.
  • Replacing a removed outline with a focus ring that only keyboard users see.
  • Sending focus to the first field that failed validation after a submit.

More examples

Focus the page after every navigation

This is App.vue. router.afterEach takes (to, from, failure); the third argument is a navigation failure object, so skipping on it avoids moving focus when a guard cancelled the navigation. nextTick() matters because afterEach fires before the new view has rendered. useTemplateRef('main') was added in Vue 3.5 and takes the ref name as a string; const main = ref<HTMLElement | null>(null) next to ref="main" is the older form of the same thing and still works. afterEach returns a function that removes the hook, which you would call in onUnmounted if this were not the root component.

Example · vue
<script setup lang="ts">
import { nextTick, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const main = useTemplateRef<HTMLElement>('main')

router.afterEach(async (to, from, failure) => {
  if (failure || to.path === from.path) return
  await nextTick()
  main.value?.focus()
})
</script>

<template>
  <a href="#main">Skip to main content</a>
  <main ref="main" id="main" tabindex="-1">
    <router-view />
  </main>
</template>

<style scoped>
main:focus-visible {
  outline: 2px dashed currentColor;
  outline-offset: 4px;
}
</style>

Focus the field that failed validation

A failed submit that only paints a red border tells a screen reader user nothing. Moving focus to the field makes the browser announce its label, its invalid state and the message linked by aria-describedby, in that order. The await nextTick() is what makes the message exist before focus lands on the input. With several fields, sort your errors into form order and focus the first one. Note @submit.prevent is shorthand for v-on:submit with the .prevent modifier, which calls event.preventDefault() for you.

Example · vue
<script setup lang="ts">
import { nextTick, ref, useId } from 'vue'

const emit = defineEmits<{
  submit: [email: string]
}>()

const email = ref('')
const error = ref('')
const field = ref<HTMLInputElement | null>(null)
const fieldId = useId()
const errorId = useId()

async function submit() {
  error.value = email.value.includes('@') ? '' : 'Enter an email address.'
  if (!error.value) {
    emit('submit', email.value)
    return
  }
  await nextTick()
  field.value?.focus()
}
</script>

<template>
  <form novalidate @submit.prevent="submit">
    <label :for="fieldId">Email address</label>
    <input
      :id="fieldId"
      ref="field"
      v-model="email"
      type="email"
      :aria-invalid="error ? 'true' : undefined"
      :aria-describedby="error ? errorId : undefined"
    />
    <p v-if="error" :id="errorId">{{ error }}</p>
    <button type="submit">Subscribe</button>
  </form>
</template>

<style scoped>
input:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}
</style>

A global focus style

One stylesheet imported from main.ts covers every component, including third-party ones, which per-component scoped styles cannot reach. :focus-visible is the hook for keyboard focus, so a mouse click on a button does not leave a ring behind. The forced-colors query keeps the ring correct in Windows high contrast mode, where custom colours are replaced by system ones. If a reviewer asks you to hide focus rings, this file is where you negotiate: a replacement, not a removal.

Example · css
/* src/assets/focus.css, imported once from src/main.ts */
:focus-visible {
  outline: 3px solid #1a73e8;
  outline-offset: 2px;
  border-radius: 2px;
}

/* Only remove the ring where you have drawn your own. */
.tab[aria-selected='true']:focus-visible {
  outline: none;
  box-shadow: inset 0 -3px 0 0 #1a73e8;
}

@media (forced-colors: active) {
  :focus-visible {
    outline-color: Highlight;
  }
}

Discussion

  • Be the first to comment on this lesson.