Semantics First

Use the element that already carries the role: real buttons, real labels, headings in order, before any ARIA.

Syntax<label :for="fieldId">Name</label> <input :id="fieldId" v-model="value" />

A screen reader never sees your CSS. It reads a tree of roles, names and states that the browser builds from your markup. Semantic HTML means picking the element that already carries the right role — button, label, nav, main, h1 — instead of adding the role back later.

What the element gives you free

<button> is focusable, activates on Enter and Space, and is announced as "Search, button". A <div> with a click handler is announced as plain text. A <label> whose for matches the input's id gives the field its name and makes the label text a click target. Inside a reusable component that id must be unique per instance, so generate it with useId(), added in Vue 3.5.

In templates :for is shorthand for v-bind:for, and @submit for v-on:submit. Each pair compiles to the same thing, so expect both forms in other people's code.

Headings are the table of contents

Many screen reader users move through a page by heading. Use one <h1>, then <h2> beneath it, and skip no levels. Level is structure; size is CSS.

What trips people up

  • A placeholder is not a label. It disappears as soon as someone types.
  • A <button> inside a <form> defaults to type="submit". Mark every other one type="button".
  • An icon-only button needs a name: visible text, visually hidden text, or aria-label.

The example renders a labelled search field, an optional hint under it, and a submit button. A screen reader reads the label, then the hint, then "Search, button".

Example

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

const props = defineProps<{
  label: string
  hint?: string
}>()

const emit = defineEmits<{
  search: [term: string]
}>()

const term = ref('')
const fieldId = useId()
const hintId = useId()

function submit() {
  emit('search', term.value)
}
</script>

<template>
  <form @submit.prevent="submit">
    <label :for="fieldId">{{ props.label }}</label>
    <input
      :id="fieldId"
      v-model="term"
      type="search"
      :aria-describedby="props.hint ? hintId : undefined"
    />
    <p v-if="props.hint" :id="hintId">{{ props.hint }}</p>
    <button type="submit">Search</button>
  </form>
</template>

<style scoped>
label {
  display: block;
  font-weight: 600;
}
</style>

When to use it

  • Giving a reusable form field a label id that stays unique when the component renders twice on one page.
  • Replacing a clickable div in an existing component with a button without changing how it looks.
  • Fixing a page whose headings jump from h1 to h4 because the levels were chosen for font size.
  • Naming the icon-only buttons in a toolbar so they are not all announced as "button".

More examples

A real button next to two fakes

The three blocks are announced and operated differently. The first is a button and needs nothing else. The second is invisible to keyboard and screen reader users. The third is what you end up writing to make a div equivalent, and it still misses the native disabled state, form submission and default styling. Reach for this comparison when someone argues that the element does not matter because they have added a click handler.

Example · vue
<script setup lang="ts">
const emit = defineEmits<{
  remove: []
}>()
</script>

<template>
  <!-- Announced as "Remove, button". Enter and Space both activate it. -->
  <button type="button" @click="emit('remove')">Remove</button>

  <!-- Announced as the text "Remove". No focus, no keyboard. -->
  <div @click="emit('remove')">Remove</div>

  <!-- The div taught to behave: four extra attributes to keep correct. -->
  <div
    role="button"
    tabindex="0"
    @click="emit('remove')"
    @keydown.enter="emit('remove')"
    @keydown.space.prevent="emit('remove')"
  >
    Remove
  </div>
</template>

Landmarks and heading order in a layout component

Screen readers offer a list of landmarks, and these five elements populate it without any role attributes. The <nav> gets aria-label because a page can have several, and the <section> is named by its own heading through aria-labelledby. The skip link is off screen until focused, which gives keyboard users a way past the navigation. Vue Router 5 already sets aria-current="page" on the exact-active RouterLink, so the current page is announced without extra work.

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

const sectionId = useId()
</script>

<template>
  <a class="skip" href="#main">Skip to main content</a>

  <header>
    <nav aria-label="Main">
      <router-link to="/">Home</router-link>
      <router-link to="/reports">Reports</router-link>
    </nav>
  </header>

  <main id="main">
    <h1>Reports</h1>
    <section :aria-labelledby="sectionId">
      <h2 :id="sectionId">Last 30 days</h2>
      <slot />
    </section>
  </main>

  <footer>
    <p>Data refreshes hourly.</p>
  </footer>
</template>

<style scoped>
.skip {
  position: absolute;
  left: -9999px;
}

.skip:focus {
  left: 0;
}
</style>

An icon-only button with a visually hidden name

The glyph is decoration, so aria-hidden="true" keeps it out of the accessibility tree; without that, some readers announce the character itself. The second span is the accessible name. Visually hidden text is preferred over aria-label here because it is translated by page translation tools and is findable by browser search. Note that display: none or visibility: hidden would remove it from the tree as well, which is exactly what you do not want.

Example · vue
<script setup lang="ts">
const emit = defineEmits<{
  close: []
}>()
</script>

<template>
  <button type="button" @click="emit('close')">
    <span aria-hidden="true">&times;</span>
    <span class="visually-hidden">Close dialog</span>
  </button>
</template>

<style scoped>
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
</style>

Discussion

  • Be the first to comment on this lesson.