Accessibility

ARIA & Semantics

Connect anchors and floating surfaces with ARIA roles, states, and accessibility relationships.

Floating surfaces are frequently teleported or mounted at the end of the <body> element to escape overflow clipping and stacking contexts.

While this solves CSS layout constraints, it severs the DOM relationship between the trigger button and the floating surface. Sighted users see a dropdown directly underneath its button, but assistive technologies (such as screen readers) have no hierarchical parent-child connection to follow.

To make floating interfaces accessible, you must bridge this gap using standard W3C WAI-ARIA roles, states, and properties.

The semantic contract

A complete floating interface connects the trigger, the surface container, and the internal items through three layers of attributes:

  1. Trigger state: Informs the user what kind of surface will open (aria-haspopup), whether it is currently visible (aria-expanded), and the ID of the controlled panel (aria-controls).
  2. Surface container: Declares the purpose of the container (role="menu", role="listbox", role="dialog", role="tooltip"), whether it is modal (aria-modal="true"), and its accessible name (aria-labelledby or aria-label).
  3. Internal items: Identifies interactive elements within lists (role="menuitem", role="option"), along with dynamic states such as aria-disabled, aria-checked, or aria-selected.

Trigger attributes

The anchor button requires attributes that describe the disclosure lifecycle:

AttributePurposeWhen to use
aria-haspopupSignals that activating the element reveals a popup. Values include "menu", "listbox", "tree", "grid", or "dialog".All popup triggers (menus, select pickers, dialog triggers).
aria-expandedCommunicates whether the popup is currently visible ("true" or "false").All popup triggers except simple tooltips.
aria-controlsPoints to the id of the floating surface element.Menus, popups, and comboboxes.
aria-describedbyReferences an element that provides supplementary descriptive text.Informational tooltips (role="tooltip").

For tooltips, use aria-describedby on the trigger instead of aria-expanded or aria-controls. A tooltip does not open an interactive popup: it describes the anchor.

Surface roles and accessible naming

Every floating surface container needs an explicit role and an accessible name according to the WAI-ARIA Authoring Practices Guide (APG):

  • Menus (role="menu"): For lists of actions or commands (such as "Edit", "Duplicate", "Delete").
  • Listboxes (role="listbox"): For lists of selectable options (such as form selects and autocomplete suggestion pickers).
  • Dialogs (role="dialog"): For interactive overlays containing forms, alerts, or multistep workflows.
  • Tooltips (role="tooltip"): For non-interactive descriptions that clarify the purpose of an ambiguous control.

Labeling dialogs and surfaces

Screen readers announce a surface container using its accessible name when focus enters.

Provide an accessible name using aria-labelledby pointing to a heading element inside the surface:

<div id="settings-dialog" role="dialog" aria-labelledby="dialog-title" aria-modal="true">
  <h2 id="dialog-title">Workspace Settings</h2>
  <!-- Dialog content -->
</div>

If the surface has no visible heading, provide a concise name directly via aria-label:

<div id="quick-actions" role="menu" aria-label="Quick actions">
  <!-- Menu items -->
</div>

Automating semantics with useRole()

Manually generating unique element IDs and synchronizing attributes across triggers and items adds boilerplate.

VFloat provides useRole to automate this contract. It monitors node.open and dynamically synchronizes ARIA attributes on both elements.

Here is a menu component using useRole:

<script setup lang="ts">
import { ref, shallowRef } from "vue";
import {
  useClick,
  useEscapeKey,
  useFloatingNode,
  useOutsideClick,
  usePosition,
  useRole,
} from "v-float";

interface ActionItem {
  id: string;
  label: string;
  disabled?: boolean;
}

const items = ref<ActionItem[]>([
  { id: "duplicate", label: "Duplicate" },
  { id: "archive", label: "Archive", disabled: true },
  { id: "delete", label: "Delete" },
]);

const anchorEl = ref<HTMLElement | null>(null);
const floatingEl = ref<HTMLElement | null>(null);
const itemEls = shallowRef<(HTMLElement | null)[]>([]);

const node = useFloatingNode({ anchorEl, floatingEl });
usePosition(node, { placement: "bottom-start" });

useClick(node);
useOutsideClick(node);
useEscapeKey(node);

// Synchronize ARIA roles and relationships automatically
useRole(node, {
  role: "menu",
  listRef: itemEls,
  disabledIndices: (index) => !!items.value[index]?.disabled,
});
</script>

<template>
  <button ref="anchorEl" type="button">Actions</button>

  <ul v-if="node.open.value" ref="floatingEl">
    <li
      v-for="(item, index) in items"
      :key="item.id"
      :ref="(el) => (itemEls[index] = el as HTMLElement | null)"
    >
      {{ item.label }}
    </li>
  </ul>
</template>

When this component runs, useRole performs the following coordination automatically:

  1. Generates an auto-incremented ID on the floating list if none exists (using Vue's native useId).
  2. Applies aria-haspopup="menu", aria-expanded="false", and aria-controls to the anchor button.
  3. Flips aria-expanded to "true" whenever node.open.value becomes true.
  4. Sets role="menu" on the floating element.
  5. Traverses listRef to set role="menuitem" on every item.
  6. Sets aria-disabled="true" on items matching disabledIndices.
  7. Restores all original DOM attributes when the composable unmounts or disables.

The responsibility boundary

useRole handles dynamic attribute coordination, but your template markup owns static document semantics:

  • Semantic HTML elements: Use <button type="button"> for triggers rather than <div @click>. Native buttons provide keyboard activation via Space and Enter for free.
  • Accessible trigger names: If a button contains only an icon, provide an aria-label directly on the button element (such as aria-label="More options").
  • Visible headings: For dialogs, create a visible header and pass its ID via labelledBy so screen readers announce the dialog title immediately on open.

Official specifications

Where to go next

  • Focus Models: Learn how to manage focus placement, trapping, and return restoration.
  • Keyboard Navigation: Implement accessible roving tabindex and virtual focus patterns.
  • useRole API: Full options, type definitions, and parameter references.

Released under the MIT License. Copyright © 2026