JAVASCRIPT
Implementing an Accessible Focus Trap Composable in Vue 3
Create a reusable Vue 3 composable to trap keyboard focus within a specific DOM element, enhancing accessibility for modals, dropdowns, and other overlays.
// src/composables/useFocusTrap.js
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
export function useFocusTrap(isActiveRef) {
const trapRef = ref(null); // Reference to the DOM element to trap focus within
let previouslyFocusedElement = null; // Store the element that had focus before trapping
const focusFirstTabbableElement = () => {
if (!trapRef.value) return;
nextTick(() => {
const tabbableElements = getTabbableElements(trapRef.value);
if (tabbableElements.length > 0) {
tabbableElements[0].focus();
}
});
};
const handleKeydown = (event) => {
if (!isActiveRef.value || event.key !== 'Tab') return;
event.preventDefault(); // Prevent default tab behavior
const tabbableElements = getTabbableElements(trapRef.value);
if (tabbableElements.length === 0) return;
const firstTabbable = tabbableElements[0];
const lastTabbable = tabbableElements[tabbableElements.length - 1];
if (event.shiftKey) { // Shift + Tab
if (document.activeElement === firstTabbable) {
lastTabbable.focus();
} else {
const index = tabbableElements.indexOf(document.activeElement);
if (index > 0) tabbableElements[index - 1].focus();
else lastTabbable.focus(); // Fallback to last if somehow not found
}
} else { // Tab
if (document.activeElement === lastTabbable) {
firstTabbable.focus();
} else {
const index = tabbableElements.indexOf(document.activeElement);
if (index !== -1 && index < tabbableElements.length - 1) tabbableElements[index + 1].focus();
else firstTabbable.focus(); // Fallback to first if somehow not found
}
}
};
const getTabbableElements = (element) => {
if (!element) return [];
return Array.from(
element.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
).filter(el => {
// Ensure element is visible and not hidden via display/visibility
const style = window.getComputedStyle(el);
return style.display !== 'none' && style.visibility !== 'hidden' && el.offsetParent !== null;
});
};
watch(isActiveRef, (newValue) => {
if (newValue) {
previouslyFocusedElement = document.activeElement;
document.addEventListener('keydown', handleKeydown);
focusFirstTabbableElement();
} else {
document.removeEventListener('keydown', handleKeydown);
if (previouslyFocusedElement && previouslyFocusedElement.focus) {
previouslyFocusedElement.focus();
}
}
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
});
return { trapRef };
}
// src/components/AccessibleModal.vue
<template>
<teleport to="#modal-target">
<div v-if="isVisible" class="modal-overlay" @click.self="$emit('close')">
<div ref="trapRef" class="modal-content" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<h2 id="modalTitle">Accessible Modal</h2>
<p>This modal traps focus inside for better accessibility.</p>
<input type="text" placeholder="First interactive element" />
<button>Do Something</button>
<button @click="$emit('close')">Close Modal</button>
<a href="#">Last focusable item</a>
</div>
</div>
</teleport>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch } from 'vue';
import { useFocusTrap } from '@/composables/useFocusTrap'; // Adjust path as needed
const props = defineProps({
isVisible: {
type: Boolean,
default: false,
},
});
defineEmits(['close']);
// Pass the isVisible prop as a ref to the composable
const isModalActive = ref(props.isVisible);
watch(() => props.isVisible, (newValue) => {
isModalActive.value = newValue;
});
const { trapRef } = useFocusTrap(isModalActive);
</script>
<style scoped>
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.6); display: flex; justify-content: center; align-items: center; z-index: 1000;
}
.modal-content {
background-color: white; padding: 30px; border-radius: 8px; box-shadow: 0 5px 25px rgba(0,0,0,0.3);
max-width: 600px; width: 90%; text-align: center;
display: flex; flex-direction: column; gap: 15px;
}
.modal-content input, .modal-content button, .modal-content a { padding: 8px 12px; border-radius: 4px; border: 1px solid #ccc; }
.modal-content button { background-color: #007bff; color: white; cursor: pointer; }
</style>
How it works: This snippet provides a `useFocusTrap` composable for Vue 3 that significantly improves accessibility for overlays like modals. When activated, it intercepts the `Tab` key to cyclically restrict keyboard focus within a specified DOM element (`trapRef`). It also saves and restores the previously focused element, ensuring users can navigate the interface predictably, especially crucial for screen reader users.