Modals
Modals are useful when the user needs to confirm an action, edit something quickly, or focus on a small flow without leaving the page.
I prefer making modals controlled by the parent component. The parent owns the open state, and the modal receives that state through props.
Basic Flow
The usual modal flow is:
- Parent stores whether the modal is open.
- Parent passes
opento the modal. - Modal renders when
openis true. - Modal emits
closewhen the user clicks cancel, the backdrop, the close button, or presses Escape when the modal primitive supports it. - Parent decides what happens next.
Basic Modal
<script setup lang="ts">
import { ref } from 'vue'
import DeletePostModal from './DeletePostModal.vue'
const isDeleteModalOpen = ref(false)
function openDeleteModal() {
isDeleteModalOpen.value = true
}
function closeDeleteModal() {
isDeleteModalOpen.value = false
}
</script>
<template>
<button type="button" @click="openDeleteModal">
Delete post
</button>
<DeletePostModal
:open="isDeleteModalOpen"
@close="closeDeleteModal"
/>
</template><script setup lang="ts">
defineProps<{
open: boolean
}>()
const emit = defineEmits<{
close: []
}>()
</script>
<template>
<div v-if="open" class="modal-backdrop" @click.self="emit('close')">
<section
class="modal-panel"
role="dialog"
aria-modal="true"
aria-labelledby="delete-post-title"
>
<h2 id="delete-post-title">Delete post?</h2>
<p>This action cannot be undone.</p>
<div class="modal-actions">
<button type="button" @click="emit('close')">
Cancel
</button>
<button type="button">
Delete
</button>
</div>
</section>
</div>
</template>The modal does not own the open state. It receives open and asks the parent to close by emitting close.
With A Composable
If the same modal state appears in several places, move the open and close logic to a composable.
import { ref } from 'vue'
export function useModal(initialValue = false) {
const isOpen = ref(initialValue)
const openModal = () => {
isOpen.value = true
}
const closeModal = () => {
isOpen.value = false
}
return { isOpen, openModal, closeModal }
}<script setup lang="ts">
import DeletePostModal from './DeletePostModal.vue'
import { useModal } from '@/composables/useModal'
const {
isOpen: isDeleteModalOpen,
openModal: openDeleteModal,
closeModal: closeDeleteModal,
} = useModal()
</script>
<template>
<button type="button" @click="openDeleteModal">
Delete post
</button>
<DeletePostModal
:open="isDeleteModalOpen"
@close="closeDeleteModal"
/>
</template>This keeps repeated modal state out of the component.
Confirm Actions
For destructive actions, the modal should only confirm the intent. The actual delete logic usually belongs in the parent or in a form/action flow.
<DeletePostModal
:open="isDeleteModalOpen"
@close="closeDeleteModal"
@confirm="deletePost"
/>This keeps the modal reusable. It does not need to know how posts are deleted.
Notes
- Keep the modal controlled by props and emits.
- Use
@click.selfif clicking the backdrop should close the modal. - Use a tested base dialog that manages focus, Escape, an accessible name, scroll locking, and focus restoration. ARIA attributes alone are not enough.
- Do not put large business flows inside the modal component.
- If a modal has too many props or actions, it may need to be split into smaller components.
- To reuse one form modal for both operations, see Create And Update With One Modal.