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.

Need help integrating this into your project?

Our team of expert developers can help you build your custom application from scratch.

Hire DigitalCodeLabs