In modern UIs, modals are ubiquitous, yet developers often fall short on accessibility or reusability. This article shows how to build a lightweight, accessible modal component with Vue 3’s Composition API, style it using Windi CSS, and document it in Storybook so it can be dropped into any project with confidence.
Why Accessibility Matters in Modals
A modal traps focus, provides context, and often communicates critical information. When built incorrectly, users with screen readers or keyboard navigation struggle.
Key Accessibility Criteria
- Focus Management: Initial focus inside, restore focus on close.
- Aria Role & Dialog: Use
role="dialog"andaria-modal="true". - Labeling: Provide
aria-labelledbyoraria-label. - Keyboard Support: Escape closes, Tab cycles inside.
- Background Inertness: Hide overlay behind
aria-hidden="true".
Setting Up the Project
Concrete tooling makes a repeatable workflow. Use npm init vue@latest to scaffold Vue 3 with Vite, then add the dependencies:
npm install @storybook/vue3@7 @storybook/addon-essentials @storybook/preview-addon @vitejs/plugin-vuenpm install windicss -Dnpm install @vueuse/core
Configure Storybook’s main.js to support Vue 3 and Windi CSS. For Vite add a vite.config.js that imports vite-plugin-windicss. This setup gives hot‑module replacement and isolated CSS scopes.
Building the Reusable Modal Component
We’ll keep logic in a single useModal composable and expose a simple API to parent components.
Composable: useModal
“`ts
import { ref, onMounted, onUnmounted } from ‘vue’
import { useEventListener } from ‘@vueuse/core’
export function useModal() {
const isOpen = ref(false)
const open = () => { isOpen.value = true }
const close = () => { isOpen.value = false }
const onEsc = () => isOpen.value && close()
useEventListener(‘keydown’, onEsc)
onMounted(() => window.addEventListener(‘click’, backgroundClick))
onUnmounted(() => window.removeEventListener(‘click’, backgroundClick))
function backgroundClick(event: MouseEvent) {
if (event.target instanceof HTMLElement && event.target.closest(‘.modal-backdrop’)) {
close()
}
}
return { isOpen, open, close }
}
“`
Template & Props
The component accepts title and closeOnOutsideClick props. It emits closed when the modal is dismissed.
“`vue
import { useModal } from ‘./useModal’
const props = defineProps()
const emit = defineEmits()
const { isOpen, open, close } = useModal()
watch(isOpen, value => value || emit(‘closed’))
“`
Slot API
Using a default slot lets developers slot any content, from forms to confirmations. The close method is exposed via the setup function for callbacks.
Accessibility Enhancements
Beyond the role attributes, we must focus element control.
Focus Trap
Integrate focus-trap‑zone library or implement a simple focus list. Example using @vueuse/core:
“`ts
import { toggle } from ‘@vueuc/utils’
const focusable = computed(() => {
return Array.from(document.querySelectorAll(‘button, [href], input, select, textarea, [tabindex]:not([tabindex=”-1″])’))
})
watch(isOpen, newVal => {
if (newVal) {
toggle(focusable.value[0], true)
focusable.value[0]?.focus()
}
})
“`
Restore Focus on Close
Capture the previously focused element before opening, then return focus once the modal is hidden.
“`ts
const previous = ref(null)
const open = () => { previous.value = document.activeElement; isOpen.value = true }
const close = () => { isOpen.value = false; previous.value?.focus?.() }
“`
Styling with Windi CSS
Windi CSS offers atomic utilities at runtime. Add utility classes directly in the template for rapid styling. For example:
“`html