Modal
Shows a window layered over the page or over another dialog.
For in-depth guides and tips on using Modal, see the Modal mechanism page.
- In
<template>:
<script setup lang="ts">
import { ref, reactive } from 'vue'
import {
HButton, HModal, HSelect, type SelectOptionBase,
} from '@holistics/design-system'
const shown = ref(false)
export interface Info {
role?: string
jobTitle?: string
}
const info = reactive<Info>({
role: undefined,
jobTitle: undefined,
})
const roles = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
] as const satisfies SelectOptionBase[]
const jobTitles = [
{ value: 'product-manager', label: 'Product Manager' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOptionBase[]
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Open
</HButton>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
>
<div class="flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Role</label>
<HSelect
v-model="info.role"
:options="roles"
placeholder="Select a role..."
class="w-3/4"
/>
</div>
<div class="mt-4 flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Job title</label>
<HSelect
v-model="info.jobTitle"
:options="jobTitles"
placeholder="Select a job title..."
class="w-3/4"
/>
</div>
</HModal>
</template>- Programmatically:
<script setup lang="ts">
import { HModalProvider } from '@holistics/design-system'
import Opener from './Opener.vue'
</script>
<template>
<HModalProvider>
<Opener />
</HModalProvider>
</template><script setup lang="ts">
import { HButton, useModal } from '@holistics/design-system'
import Modal from './Modal.vue'
const { open } = useModal()
async function onClick () {
const { state, data } = await open(Modal)
/**
* If dismissed: 'dismiss', undefined
* If resolved: 'resolve', []
*/
console.log(state, data)
}
</script>
<template>
<HButton
type="primary-highlight"
@click="onClick"
>
Open
</HButton>
</template><script setup lang="ts">
import { HModal, HSelect, type SelectOptionBase } from '@holistics/design-system'
import { reactive } from 'vue'
const shown = defineModel<boolean>('shown', { required: true })
export interface Info {
role?: string
jobTitle?: string
}
const info = reactive<Info>({
role: undefined,
jobTitle: undefined,
})
const roles = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
] as const satisfies SelectOptionBase[]
const jobTitles = [
{ value: 'product-manager', label: 'Product Manager' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOptionBase[]
</script>
<template>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
>
<div class="flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Role</label>
<HSelect
v-model="info.role"
:options="roles"
placeholder="Select a role..."
class="w-3/4"
/>
</div>
<div class="mt-4 flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Job title</label>
<HSelect
v-model="info.jobTitle"
:options="jobTitles"
placeholder="Select a job title..."
class="w-3/4"
/>
</div>
</HModal>
</template>Examples
Overflow content
<script setup lang="ts">
import { ref } from 'vue'
import { HButton, HModal } from '@holistics/design-system'
const shown = ref(false)
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Default
</HButton>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
>
<div class="h-screen w-[100rem] bg-success-secondary-default" />
</HModal>
</template><script setup lang="ts">
import { ref } from 'vue'
import { HButton, HModal } from '@holistics/design-system'
const shown = ref(false)
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Document Scrollable
</HButton>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
document-scrollable
>
<div class="h-screen w-[100rem] bg-success-secondary-default" />
</HModal>
</template>Async body
Sometimes you want to lazy load the modal content. A page can hold many modals, and a user won't open every one of them in a session. Some modals also carry heavy code, such as third-party libraries and complex components. Lazy loading that content cuts the bundle size the page has to load.
<HModal> supports this out of the box, with an animation and a loading indicator.
<script setup lang="ts">
import { ref } from 'vue'
import { HButton } from '@holistics/design-system'
import Modal from './Modal.vue'
const shown = ref(false)
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Open
</HButton>
<Modal v-model:shown="shown" />
</template><script lang="ts">
/* eslint-disable import/first */
import { defineAsyncComponent } from 'vue'
const ModalBody = defineAsyncComponent({
loader: async () => {
await new Promise((res) => { setTimeout(res, 2000) }) // [!code warning] // ⚠️ Just for demo purpose
return import('./ModalBody.vue')
},
})
</script>
<script setup lang="ts">
import { HModal } from '@holistics/design-system'
const shown = defineModel<boolean>('shown', { required: true })
</script>
<template>
<HModal
v-model:shown="shown"
title="Some Modal"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
>
<ModalBody />
</HModal>
</template><script setup lang="ts">
import { reactive } from 'vue'
import { HSelect, type SelectOptionBase } from '@holistics/design-system'
export interface Info {
role?: string
jobTitle?: string
}
const info = reactive<Info>({
role: undefined,
jobTitle: undefined,
})
const roles = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
] as const satisfies SelectOptionBase[]
const jobTitles = [
{ value: 'product-manager', label: 'Product Manager' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOptionBase[]
</script>
<template>
<div class="flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Role</label>
<HSelect
v-model="info.role"
:options="roles"
placeholder="Select a role..."
class="w-3/4"
/>
</div>
<div class="mt-4 flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Job title</label>
<HSelect
v-model="info.jobTitle"
:options="jobTitles"
placeholder="Select a job title..."
class="w-3/4"
/>
</div>
</template>Only use defineAsyncComponent in <script>
In <script setup>, Vue redefines every variable and function when the component is created, then destroys them after unmount. Define the async body component in the global context, in a normal <script> block instead.
Show/hide modal programmatically
When you show a modal programmatically, you pass the modal component to the open() function. Don't wrap it in defineAsyncComponent again. Doing so breaks the animations.
Manually handle initial focus
Even though the modal body is lazy loaded, focus trap is still enabled on the container. This means the first tabbable element can't be focused automatically while the body is loading. Handle the initial focus yourself with onMounted inside the body component, or with the @bodyResolved event. Remember to call nextTick().
Quickly pass props to and emit data from the body component
Use v-bind="props" and re-emit the @dismiss and @resolve events. You can also define the emits as props with an on prefix, which marks them as events for the Vue compiler.
This approach has one caveat. <HModal> emits the @dismiss and @resolve events itself, so you can't use the automatic data flow mechanism. To work around this, use the no-footer prop and fill the #body slot with <HModalFooter> components. For example:
<script setup lang="ts">
import { ref } from 'vue'
import { HButton } from '@holistics/design-system'
import Modal from './Modal.vue'
import type { Info } from './ModalBody.vue'
const shown = ref(false)
function onResolve (info: Info) {
console.log(info)
}
</script>
<template>
<HButton
type="primary-highlight"
@click="shown = true"
>
Open
</HButton>
<Modal
v-model:shown="shown"
:info="{ role: 'admin' }"
@resolve="onResolve"
/>
</template><script lang="ts">
/* eslint-disable import/first */
import { computed, defineAsyncComponent } from 'vue'
const ModalBody = defineAsyncComponent({
loader: async () => {
await new Promise((res) => { setTimeout(res, 2000) }) // [!code warning] // ⚠️ Just for demo purpose
return import('./ModalBody.vue')
},
})
</script>
<script setup lang="ts">
import { HModal } from '@holistics/design-system'
import type { Props, Emits } from './ModalBody.vue'
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const shown = defineModel<boolean>('shown', { required: true })
const title = computed(() => {
const { role } = props.info
return 'Some Modal' + (role ? ` (${role})` : '')
})
</script>
<template>
<HModal
v-model:shown="shown"
:title="title"
description="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
no-footer
>
<template #body="{ dismiss, resolve }">
<ModalBody
v-bind="props"
@dismiss="() => { emit('dismiss'); dismiss() }"
@resolve="(info) => { emit('resolve', info); resolve() }"
/>
</template>
</HModal>
</template><script setup lang="ts">
import { HSelect, type SelectOptionBase, HModalFooter } from '@holistics/design-system'
import { ref } from 'vue'
defineOptions({ inheritAttrs: false })
export interface Info {
role?: string
jobTitle?: string
}
export interface Props {
info: Info
}
export type Emits = {
dismiss: []
resolve: [info: Info]
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const infoLocal = ref({ ...props.info })
const roles = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
] as const satisfies SelectOptionBase[]
const jobTitles = [
{ value: 'product-manager', label: 'Product Manager' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOptionBase[]
</script>
<template>
<div class="flex-1 overflow-auto p-6">
<div class="flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Role</label>
<HSelect
v-model="infoLocal.role"
:options="roles"
placeholder="Select a role..."
class="w-3/4"
/>
</div>
<div class="mt-4 flex items-center space-x-4">
<label class="w-1/4 text-title-xs">Job title</label>
<HSelect
v-model="infoLocal.jobTitle"
:options="jobTitles"
placeholder="Select a job title..."
class="w-3/4"
/>
</div>
</div>
<HModalFooter
dismiss-button="Cancel"
resolve-button="Submit"
@dismiss="emit('dismiss')"
@resolve="emit('resolve', infoLocal)"
/>
</template>Nested modals
<script setup lang="ts">
import { ref } from 'vue'
import { HButton, HModal } from '@holistics/design-system'
const firstModalShown = ref(false)
const secondModalShown = ref(false)
</script>
<template>
<HButton
type="primary-highlight"
@click="firstModalShown = true"
>
Open
</HButton>
<HModal
v-model:shown="firstModalShown"
title="Modal 1"
>
<HButton
type="secondary-default"
@click="secondModalShown = true"
>
Open Nested
</HButton>
<HModal
v-model:shown="secondModalShown"
title="Modal 2"
>
Hello world!
</HModal>
</HModal>
</template>API
Pass-through: <div>
What does this mean?
All props, events, and attrs that are not specified in the tables below will be passed to the element/component described above.
Props
| Name | Type | Description |
|---|---|---|
shown * | boolean | |
teleportTo | string | RendererElement | null= "body" | Specify target container. Can either be a selector or an actual element. {@link https://vuejs.org/api/built-in-components.html#teleport} |
teleportDisabled | boolean | When {@link https://vuejs.org/api/built-in-components.html#teleport} |
teleportDefer | boolean | When {@link https://vuejs.org/api/built-in-components.html#teleport} |
size | "sm" | "md" | "lg" | "xl" | "full" | "fullscreen"= "md" | |
position | "top" | "right" | "bottom" | "left" | "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right"= MODAL_POSITIONS[1] | |
fullHeight | boolean | |
maskTransparent | boolean | |
maskBlurred | boolean | |
noCloseButton | boolean | |
noHeader | boolean | |
title | string | |
description | string | |
noFooter | boolean | |
dismissButton | string | ButtonPropsPreTyped= "Cancel" | |
resolveButton | string | ButtonPropsPreTyped= "Confirm" | |
hasCheckbox | boolean | |
checkbox | boolean | |
checkboxLabel | string= "Don't show this again" | |
leftText | string | |
disableFocusTrap | boolean | By default, focus cannot escape the modal container via keyboard, pointer, or a programmatic focus. To disable it,
set this to |
initialFocus | string | FocusableElement | null | |
documentScrollable | boolean | |
transitionDisabled | boolean | |
preventClickOutside | boolean | |
preventPressEscape | boolean | |
preventCloseAnimationDisabled | boolean | |
dismissLoading | boolean | |
resolveLoading | boolean | |
autoFocusElement | string | true | FocusableElement | null | By default, when mounted or when calling the exposed
NOTE: Set this to |
disableOutsidePointerEvents | boolean= true | When To interact with any element within lower layers, user will need to click outside of all layers with
This can be used as a way to force all lower layers to not emit |
immediate | boolean |
Events
| Name | Parameters | Description |
|---|---|---|
@pointerDownOutside | [event: PointerDownOutsideEvent] | |
@keydownEscape | [event: KeyboardEvent] | |
@autoFocusOnUnmount | [event: CustomEvent<any>] | |
@update:shown | [shown: boolean] | |
@update:checkbox | [checked: boolean] | |
@update:dismissLoading | [loading: boolean] | |
@update:resolveLoading | [loading: boolean] | |
@bodyResolved | [resolved: boolean] | |
@autoFocusOnBodyResolved | [event: CustomEvent<any>] | |
@dismiss | MaybeArray<() => FalsableReturn | Promise<FalsableReturn>> | |
@resolve | MaybeArray<() => FalsableReturn | Promise<FalsableReturn>> |
Slots
| Name | Scoped | Description |
|---|---|---|
#header | SlotPropsHeader | |
#title | SlotPropsHeader | |
#description | SlotPropsHeader | |
#body | SlotPropsDefault | |
#default | SlotPropsDefault | |
#body-fallback | any | |
#footer | SlotPropsDefault | |
#footer-left | SlotPropsBase & SlotPropsWithCheckbox | |
#checkbox-label | any | |
#dismiss-button | any | |
#resolve-button | any |
Exposed
| Name | Type | Description |
|---|---|---|
elementRef | HTMLDivElement | undefined | |
containerRef | HTMLDivElement | undefined |