Skip to content

Modal

Experimental

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>:
vue
<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:
vue
<script setup lang="ts">
import { HModalProvider } from '@holistics/design-system'
import Opener from './Opener.vue'
</script>

<template>
  <HModalProvider>
    <Opener />
  </HModalProvider>
</template>
vue
<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>
vue
<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

vue
<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>
vue
<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.

vue
<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>
vue
<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>
vue
<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:

vue
<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>
vue
<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>
vue
<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

vue
<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

NameTypeDescription
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 true, the content will remain in its original location instead of moved into the target container. Can be changed dynamically.

{@link https://vuejs.org/api/built-in-components.html#teleport}

teleportDefer 
boolean

When true, the Teleport will defer until other parts of the application have been mounted before resolving its target. (3.5+)

{@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 true.

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 autoFocus() function, the first focusable element within the scope will be automatically focused. To auto-focus something else, set this prop to:

  • true: to auto-focus the container
  • string: query selector to search for an element within the container
  • HTMLElement | SVGElement

NOTE: Set this to null or undefined will result in the default behavior. To disable auto-focus on mount, prevent the emitted @auto-focus-on-mount event instead.

disableOutsidePointerEvents 
boolean
= true

When true, only the top layer with disableOutsidePointerEvents = true has pointer-events: auto, all lower layers will have pointer-events: none

To interact with any element within lower layers, user will need to click outside of all layers with disableOutsidePointerEvents = true until reaching the desired element.

This can be used as a way to force all lower layers to not emit @pointer-down-outside while this layer is rendered.

immediate 
boolean

Events

NameParametersDescription
@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

NameScopedDescription
#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

NameTypeDescription
elementRef
HTMLDivElement | undefined
containerRef
HTMLDivElement | undefined