Доступные компоненты во Vue
Vue отлично подходит для сборки сложных UI, но фреймворк не делает интерфейс доступным автоматически. Как только компонент становится переиспользуемым — модалка, вкладки, выпадающий список, аккордеон — вместе с его API появляются обязательства: управлять фокусом, объявлять изменения скринридеру, поддерживать клавиатуру и не ломать ожидания пользователей вспомогательных технологий.
Доступность (a11y) — это не отдельная «галочка» в конце проекта, а часть проектирования компонента. В этой статье разберём, как во Vue управлять фокусом и делать focus trap для модалок на <Teleport>, изолировать фон атрибутом inert, связывать aria-live с <Transition>, реализовывать доступные паттерны tabs, dialog, disclosure и combobox, а также уважать prefers-reduced-motion.
Содержание
- Зачем нужна доступность компонентов
- Семантика и ARIA в компонентах
- Модалки на Teleport: фокус и focus trap
- Инертный фон при открытой модалке
- Динамические объявления: aria-live и Transition
- Клавиатурные паттерны
- prefers-reduced-motion
- Тестирование доступности
- Лучшие практики и антипаттерны
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Зачем нужна доступность компонентов
Когда компонент используется один раз в контролируемом месте, многие проблемы незаметны. Как только он превращается в библиотечный — с пропсами, слотами и emits — ответственность растёт: его будут ставить в разные контексты, переиспользовать в формах, модалках и таблицах. Общая теория доступности (семантика, landmarks, ARIA) разобрана в разделе HTML/CSS; здесь важна именно Vue-специфика: управление фокусом через реактивные ссылки, изоляция фона при телепортации, объявления об изменениях и клавиатурное поведение.
Ключевая идея: доступность — это часть контракта компонента, такая же, как пропсы и события. Если модалка не возвращает фокус при закрытии, а вкладки не переключаются стрелками, компонент сломан для пользователя клавиатуры или скринридера — даже если визуально он безупречен.
Семантика и ARIA в компонентах
Сначала нативный элемент
Первый вопрос при проектировании компонента: нельзя ли обойтись нативным HTML? <button>, <a href>, <input>, <select>, <details> уже включают семантику, фокус и клавиатурное поведение. Vue-компонент BaseButton, который рендерит <div> вместо <button>, теряет всё это.
<!-- ❌ Плохо: теряются фокус, Enter/Space и роль -->
<template>
<div class="btn" @click="emit('click')">
<slot />
</div>
</template>
<!-- ✅ Хорошо: нативная кнопка сохраняет всё поведение -->
<template>
<button type="button" class="btn" @click="emit('click')">
<slot />
</button>
</template>
Если нужно, чтобы компонент умел быть и ссылкой, и кнопкой, используют динамический тег <component :is> и передают нужные атрибуты, а не «эмулируют» поведение на div.
role, tabindex и aria-*
Когда нативный элемент недоступен (например, сложный виджет), компонент берёт на себя роль и состояния. Соглашение простое:
| Что | Атрибут | Когда нужен |
|---|---|---|
| Роль виджета | role="dialog", role="tablist", role="combobox" | Нет подходящего HTML-элемента |
| Порядок фокуса | tabindex="0" / tabindex="-1" | Управляемый фокус, roving tabindex |
| Состояние | aria-expanded, aria-selected, aria-invalid | Виджет меняет состояние |
| Связи | aria-controls, aria-labelledby, aria-describedby | Элемент управляет другим |
| Скрытие | inert, aria-hidden | Фон, декоративные элементы |
Правило номер один: не дублируйте встроенные роли. <button role="button"> и <nav role="navigation"> избыточны. ARIA добавляется только тогда, когда элемент или виджет меняет своё поведение относительно нативной семантики.
<script setup>
defineProps({ expanded: Boolean });
const emit = defineEmits(['toggle']);
</script>
<template>
<button
type="button"
:aria-expanded="expanded"
aria-controls="filters-panel"
@click="emit('toggle')"
>
Фильтры
</button>
</template>
Здесь role не нужен — кнопка и так кнопка, — но aria-expanded и aria-controls сообщают скринридеру, что кнопка управляет раскрывающейся областью.
Модалки на Teleport: фокус и focus trap
Модальное окно — самый частый источник проблем с доступностью. Его принято рендерить через <Teleport to="body">, чтобы избежать конфликтов с overflow и z-index, но телепортация не добавляет доступности автоматически: нужно управлять фокусом вручную.
Перенос фокуса при открытии
Когда модалка открывается, фокус должен перейти внутрь диалога. Иначе пользователь клавиатуры продолжит «ходить» по контенту под оверлеем, не видя этого. Варианты:
- фокус на первый интерактивный элемент (кнопку «Отмена», поле ввода);
- фокус на сам контейнер диалога с
tabindex="-1"— если первый элемент может быть деструктивным (например, «Удалить»).
Возврат фокуса при закрытии
Когда модалка закрывается, фокус обязан вернуться на элемент, который её открыл. Потерянный фокус сбрасывается в начало страницы — пользователь теряет контекст.
Ловушка фокуса
Пока диалог открыт, Tab не должен уводить фокус на фон. Это и есть focus trap: на последнем элементе Tab переводит на первый, на первом Shift+Tab — на последний.
// composables/useFocusTrap.ts
import { watch, nextTick, onUnmounted, type Ref } from 'vue';
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
export function useFocusTrap(
container: Ref<HTMLElement | null>,
active: Ref<boolean>,
) {
let previouslyFocused: HTMLElement | null = null;
const getFocusable = () =>
container.value
? Array.from(container.value.querySelectorAll<HTMLElement>(FOCUSABLE))
: [];
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const focusable = getFocusable();
if (focusable.length === 0) {
event.preventDefault();
container.value?.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const current = document.activeElement;
if (event.shiftKey && (current === first || current === container.value)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && current === last) {
event.preventDefault();
first.focus();
}
}
watch(active, async (isActive) => {
if (isActive) {
previouslyFocused = document.activeElement as HTMLElement;
await nextTick(); // ждём, пока Teleport отрендерит контейнер
(getFocusable()[0] ?? container.value)?.focus();
document.addEventListener('keydown', onKeydown);
} else {
document.removeEventListener('keydown', onKeydown);
previouslyFocused?.focus();
previouslyFocused = null;
}
});
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
}
Компонент модалки связывает ref контейнера с состоянием открытия:
<script setup>
import { computed, ref } from 'vue';
import { useFocusTrap } from '@/composables/useFocusTrap';
const props = defineProps({ open: Boolean });
const emit = defineEmits(['close']);
const dialog = ref<HTMLElement | null>(null);
const isOpen = computed(() => props.open);
useFocusTrap(dialog, isOpen);
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="open"
ref="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
class="modal"
@keydown.esc="emit('close')"
>
<h2 id="modal-title">Подтвердите действие</h2>
<slot />
<button type="button" @click="emit('close')">Отмена</button>
</div>
</Transition>
</Teleport>
</template>
Важные детали:
aria-modal="true"сообщает вспомогательным технологиям, что остальной контент недоступен.aria-labelledbyсвязывает диалог с его заголовком — диалог получает доступное имя.tabindex="-1"делает контейнер фокусируемым программно, но не включает его в порядок табуляции.- Закрытие по
Escape— ожидаемое поведение диалога; обработчик висит на контейнере, куда и попадает фокус. await nextTick()обязателен: при<Transition>/<Teleport>контейнер появляется в DOM не синхронно, а на следующем тике.
Нативный <dialog> как альтернатива
Прежде чем писать ловушку фокуса самому, стоит рассмотреть нативный элемент <dialog>. Вызов showModal() бесплатно даёт модальность, ловушку фокуса, закрытие по Escape, возврат фокуса и ::backdrop:
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({ open: Boolean });
const dialog = ref<HTMLDialogElement | null>(null);
watch(
() => props.open,
(open) => {
if (open) dialog.value?.showModal();
else dialog.value?.close();
},
);
</script>
<template>
<dialog ref="dialog" @close="$emit('close')">
<h2>Подтвердите действие</h2>
<button type="button" @click="dialog?.close()">Отмена</button>
</dialog>
</template>
<dialog> поддерживается всеми современными браузерами, поэтому для стандартной модалки это самый короткий путь к доступности. Самодельный focus trap нужен, когда требуется нестандартное поведение или поддержка очень старых движков.
Инертный фон при открытой модалке
Даже с focus trap остаётся проблема: контент под оверлеем остаётся в дереве доступности. Пользователь скринридера может прочитать его, «пройдя» виртуальным курсором за пределы диалога, а ссылки фона могут получить фокус, если trap дал сбой.
Раньше для этого использовали aria-hidden="true" на корневом контейнере приложения. Проблема в том, что aria-hidden не убирает элементы из порядка фокуса: получается конфликт — элемент скрыт для скринридера, но всё ещё фокусируем. Это ошибка доступности.
Современное решение — HTML-атрибут inert. Он делает поддерево целиком неинтерактивным: элементы не фокусируются, не кликаются и исчезают из дерева доступности.
<script setup>
import { ref, watchEffect } from 'vue';
const isOpen = ref(false);
watchEffect(() => {
// модалка телепортирована в body, значит фон — это корень приложения
const root = document.getElementById('app');
if (root) root.inert = isOpen.value;
});
</script>
Если модалка рендерится внутри дерева (без <Teleport>), inert удобно навесить прямо в шаблоне — браузеры выставляют его как DOM-свойство:
<template>
<div class="app" :inert="isModalOpen">
<AppHeader />
<router-view />
</div>
<Modal :open="isModalOpen" @close="isModalOpen = false" />
</template>
Практическое правило: для модалки используйте и focus trap, и inert (или нативный <dialog>), потому что они закрывают разные сценарии — порядок табуляции и доступность дерева соответственно.
Динамические объявления: aria-live и Transition
Скринридер не «видит» изменения на экране — ему нужно сообщить о них. Для этого служат live-регионы: role="status" (вежливое объявление, aria-live="polite") и role="alert" (срочное, aria-live="assertive").
<script setup>
import { ref } from 'vue';
const message = ref('');
function save() {
message.value = 'Изменения сохранены';
}
</script>
<template>
<button type="button" @click="save">Сохранить</button>
<div class="visually-hidden" role="status" aria-live="polite" aria-atomic="true">
{{ message }}
</div>
</template>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
Живой регион должен существовать заранее
Главная ошибка — создавать live-регион вместе с текстом через v-if:
<!-- ❌ Скринридер часто не озвучит: регион появился вместе с сообщением -->
<div v-if="message" role="status" aria-live="polite">
{{ message }}
</div>
Когда узел добавляется в DOM уже с текстом, вспомогательные технологии не успевают подписаться на изменения и просто не замечают объявление. Регион должен присутствовать в DOM постоянно, а меняться должен только его текст — как в первом примере.
Связка с Transition
Соблазн обернуть уведомление в <Transition>, чтобы оно плавно появлялось, усугубляет проблему: <Transition> с v-if удаляет узел из DOM после анимации и создаёт заново при следующем показе. Есть два рабочих подхода:
- Разделить визуальный и доступный слои. Красивый toast с
aria-hidden="true"визуально, а объявление отдаёт отдельный постоянный live-регион. - Анимировать прозрачность, не удаляя узел. Держать контейнер постоянным, менять текст и анимировать opacity/visibility.
<template>
<!-- визуальный toast: не озвучивается, чтобы не дублировать -->
<Transition name="toast">
<div v-if="message" class="toast" aria-hidden="true">{{ message }}</div>
</Transition>
<!-- постоянный live-регион: отвечает только за объявление -->
<div class="visually-hidden" role="status" aria-live="polite">
{{ message }}
</div>
</template>
Так анимация и доступность не мешают друг другу. Подробнее про сам <Transition> — в отдельной статье раздела.
Клавиатурные паттерны
Доступный виджет должен работать без мыши. Ниже — четыре базовых паттерна из WAI-ARIA Authoring Practices, которые чаще всего приходится реализовывать во Vue.
Tabs
Роли: tablist → tab + tabpanel. Фокус остаётся внутри tablist, переключение — стрелками. Порядок табуляции содержит только активную вкладку — это roving tabindex.
<script setup>
import { ref } from 'vue';
const tabs = [
{ id: 'profile', label: 'Профиль' },
{ id: 'security', label: 'Безопасность' },
{ id: 'billing', label: 'Оплата' },
];
const active = ref(tabs[0].id);
const tabRefs = ref([]);
function onKeydown(event, index) {
let next = index;
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = tabs.length - 1;
else return;
event.preventDefault();
active.value = tabs[next].id;
tabRefs.value[next]?.focus();
}
</script>
<template>
<div role="tablist" aria-label="Настройки аккаунта">
<button
v-for="(tab, index) in tabs"
:key="tab.id"
:ref="(el) => (tabRefs[index] = el)"
role="tab"
:id="`tab-${tab.id}`"
:aria-selected="active === tab.id"
:aria-controls="`panel-${tab.id}`"
:tabindex="active === tab.id ? 0 : -1"
@click="active = tab.id"
@keydown="onKeydown($event, index)"
>
{{ tab.label }}
</button>
</div>
<div
v-for="tab in tabs"
v-show="active === tab.id"
:key="tab.id"
role="tabpanel"
:id="`panel-${tab.id}`"
:aria-labelledby="`tab-${tab.id}`"
tabindex="0"
>
<slot :name="tab.id" />
</div>
</template>
aria-selected сообщает активную вкладку, aria-controls/aria-labelledby связывают вкладку и панель, tabindex="-1" убирает неактивные вкладки из табуляции, а tabindex="0" на панели позволяет перейти в неё клавиатурой.
Disclosure (аккордеон)
Раскрывающийся блок — кнопка с aria-expanded и aria-controls. Самый простой доступный вариант — нативный <details>/<summary>, который умеет это из коробки.
<template>
<!-- нативно и доступно -->
<details>
<summary>Дополнительные настройки</summary>
<p>Содержимое, скрытое по умолчанию.</p>
</details>
</template>
Если нужен полный контроль над разметкой, повторяем контракт вручную:
<script setup>
import { ref } from 'vue';
const isOpen = ref(false);
</script>
<template>
<h3>
<button
type="button"
:aria-expanded="isOpen"
aria-controls="extra-settings"
@click="isOpen = !isOpen"
>
Дополнительные настройки
</button>
</h3>
<div v-show="isOpen" id="extra-settings">
<p>Содержимое, скрытое по умолчанию.</p>
</div>
</template>
Обратите внимание: для скрытия используется v-show, а не v-if. v-show оставляет элемент в DOM и переключает display, поэтому ссылка aria-controls всегда остаётся валидной.
Dialog
Диалог разобран выше: role="dialog", aria-modal="true", доступное имя через aria-labelledby, focus trap, Escape для закрытия и inert для фона. Если контент диалога загружается асинхронно, добавьте aria-busy="true" на время загрузки.
Combobox
Combobox — самый сложный из базовых паттернов: поле ввода с выпадающим списком. Фокус остаётся в поле, а активный вариант помечается через aria-activedescendant (фокус не перемещается в список).
<template>
<div class="combobox">
<label id="city-label" for="city">Город</label>
<input
id="city"
v-model="query"
type="text"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
:aria-expanded="isOpen"
aria-controls="city-listbox"
:aria-activedescendant="activeId"
@input="isOpen = true"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter="selectActive"
@keydown.esc="isOpen = false"
/>
<ul v-show="isOpen" id="city-listbox" role="listbox" aria-labelledby="city-label">
<li
v-for="(option, index) in filtered"
:id="`city-opt-${option.id}`"
:key="option.id"
role="option"
:aria-selected="index === activeIndex"
@click="select(option)"
>
{{ option.label }}
</li>
</ul>
</div>
</template>
Контракт combobox:
| Атрибут | Назначение |
|---|---|
role="combobox" на <input> | Поле объединяет ввод и выбор из списка |
aria-expanded | Открыт ли список |
aria-controls | Ссылка на listbox |
aria-autocomplete="list" | Автодополнение из списка |
aria-activedescendant | id активного role="option" |
aria-selected на опции | Текущий выбор |
Клавиатура: ↓/↑ перемещают активный вариант, Enter выбирает, Escape закрывает список, Home/End — к границам. Не забывайте про type-ahead (ввод символов переводит к подходящему варианту) — для сложных случаев проще взять проверенную библиотеку, чем реализовывать всё с нуля.
prefers-reduced-motion
Анимации могут вызывать головокружение, тошноту и потерю ориентации у людей с вестибулярными расстройствами. Пользователь может сообщить об этом системе, а медиапризнак prefers-reduced-motion — передать предпочтение в CSS и JS.
CSS-подход — обесценить длительности переходов и анимаций:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
JS-подход нужен, когда анимацией управляет <Transition>. Можно прочитать предпочтение напрямую или через VueUse:
import { usePreferredReducedMotion } from '@vueuse/core';
const prefersReduced = usePreferredReducedMotion(); // Ref<'reduce' | 'no-preference'>
<template>
<Transition :name="prefersReduced === 'reduce' ? 'none' : 'fade'">
<div v-if="isOpen">Содержимое</div>
</Transition>
</template>
Более аккуратный вариант — не менять имя, а обнулять длительность через проп duration:
<Transition :duration="prefersReduced === 'reduce' ? 0 : 300" name="fade">
<div v-if="isOpen">Содержимое</div>
</Transition>
Это пересекается с общей темой анимаций: prefers-reduced-motion уже упоминается в статье про <Transition> и <TransitionGroup>, но здесь важно помнить, что отключение анимации — не украшение, а требование доступности.
Тестирование доступности
Доступность проверяется в три слоя.
1. Линтер. eslint-plugin-vuejs-accessibility ловит типичные ошибки прямо в SFC: alt без текста, role без обязательных атрибутов, обработчики клика на нефокусируемых элементах.
// eslint.config.js
import vueA11y from 'eslint-plugin-vuejs-accessibility';
export default [
...vueA11y.configs['flat/recommended'],
];
2. Автоматические проверки. vitest-axe / jest-axe прогоняют правила axe-core на отрендеренном компоненте:
import { mount } from '@vue/test-utils';
import { axe } from 'vitest-axe';
import { expect, it } from 'vitest';
import Tabs from './Tabs.vue';
it('не имеет нарушений доступности', async () => {
const wrapper = mount(Tabs);
expect(await axe(wrapper.element)).toHaveNoViolations();
});
В end-to-end тестах то же самое делает @axe-core/playwright на реальной странице.
3. Ручная проверка. Автоматика находит лишь часть проблем. Обязательный минимум: пройти интерфейс только клавиатурой (Tab, Shift+Tab, Enter, Space, стрелки, Escape), проверить видимый фокус и логичный порядок, включить скринридер (NVDA, VoiceOver) и пройти основной сценарий, увеличить страницу до 200%.
Лучшие практики и антипаттерны
Делайте
- Начинайте с нативного элемента.
<button>вместо<div role="button">,<details>вместо самодельного аккордеона,<dialog>вместо самодельной модалки. - Связывайте диалог с заголовком через
aria-labelledbyи ставьтеaria-modal="true". - Управляйте фокусом при открытии и закрытии модалки: переносите внутрь и возвращайте на триггер.
- Изолируйте фон атрибутом
inert— он снимает и фокус, и доступность. - Держите live-регион постоянным и меняйте только текст.
- Используйте
v-showдляaria-controls-связей, чтобы ссылка не ломалась. - Уважайте
prefers-reduced-motionи:focus-visibleдля видимого фокуса.
Не делайте
- Не убирайте обводку фокуса без замены.
/* ❌ Пользователь клавиатуры теряет ориентацию */
button:focus { outline: none; }
/* ✅ Замена видимого фокуса */
button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
- Не используйте положительный
tabindex.tabindex="1"ломает естественный порядок; для roving tabindex нужны только0и-1. - Не подменяйте
aria-hiddenизоляцией фона. Он не мешает фокусу попасть на скрытый элемент — используйтеinert. - Не создавайте live-регион одновременно с текстом. Объявление потеряется.
- Не вешайте
@clickнаdiv. Если элемент интерактивен — это<button>или<a href>. - Не полагайтесь только на цвет. Ошибка формы должна иметь текст или
aria-invalidиaria-describedby, а не только красную рамку. - Не забывайте про combobox-детали. Без
aria-activedescendant, стрелок и type-ahead виджет недоступен для клавиатуры.
Ключевые тезисы для интервью
- Нативные элементы (
<button>,<dialog>,<details>) дают семантику, фокус и клавиатуру бесплатно; ARIA добавляют только там, где их не хватает, и не дублируют встроенные роли. - Модалка на
<Teleport>требует трёх вещей: переноса фокуса внутрь, ловушкиTabи возврата фокуса на триггер;await nextTick()обязателен, потому что узел появляется в DOM не синхронно. inertизолирует фон целиком — убирает элементы и из порядка фокуса, и из дерева доступности, тогда какaria-hiddenоставляет их фокусируемыми.- Live-регион (
role="status"/role="alert") должен существовать в DOM до изменения текста, поэтому визуальный toast и объявление разделяют. - Клавиатурные паттерны (tabs, dialog, disclosure, combobox) строятся на ролях и состояниях: roving tabindex оставляет в табуляции один элемент, а combobox держит фокус в поле через
aria-activedescendant. prefers-reduced-motionобязателен: длительности обнуляют через CSS-медиазапрос илиduration/cssу<Transition>.- Доступность проверяют тремя слоями — линтер (
eslint-plugin-vuejs-accessibility), axe-тесты и ручной проход клавиатурой со скринридером.
Заключение
Vue не делает интерфейс доступным автоматически, но даёт всё необходимое, чтобы собрать доступные компоненты: реактивные ссылки для управления фокусом, <Teleport> для модалок, <Transition> для визуального слоя и прямую работу с DOM-API в хуках. Доступность здесь — это контракт компонента наравне с пропсами и событиями.
Ключевые правила: начинайте с нативного элемента, управляйте фокусом при открытии и закрытии модалки, изолируйте фон через inert, держите live-регион постоянным и уважайте prefers-reduced-motion. Для сложных виджетов — tabs, dialog, combobox — опирайтесь на паттерны WAI-ARIA APG и не забывайте про aria-selected, aria-activedescendant и roving tabindex.
Попробуйте пройти свой интерфейс только клавиатурой и вслух проговорить, что должно услышать приложение при сохранении, открытии диалога и переключении вкладок. Это упражнение выявляет больше проблем, чем любой чеклист, и быстро превращает доступность в привычку проектирования.