How to Build a Reusable Accessible Modal Component in Vue 3 (Composition API) with Storybook and Windi CSS

User avatar placeholder
Written by Tamzid Ahmed

October 7, 2026

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" and aria-modal="true".
  • Labeling: Provide aria-labelledby or aria-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:

  1. npm install @storybook/vue3@7 @storybook/addon-essentials @storybook/preview-addon @vitejs/plugin-vue
  2. npm install windicss -D
  3. npm 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

Leave a Comment