Skip to content

Modal

Experimental

A window overlaid on either the primary window or another dialog window.

💡 For in-depth guides and tips when using Modal, please refer to the Modal Mechanism page .

  • In <template>:
vue
<script setup lang="ts">
import { ref, reactive } from 'vue'
import {
  HButton, HModal, HSelect, type SelectOption,
} 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 SelectOption[]

const jobTitles = [
  { value: 'product-manager', label: 'Product Manager' },
  { value: 'engineer', label: 'Engineer' },
  { value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOption[]
</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 SelectOption } 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 SelectOption[]

const jobTitles = [
  { value: 'product-manager', label: 'Product Manager' },
  { value: 'engineer', label: 'Engineer' },
  { value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOption[]
</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-green-50" />
  </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-green-50" />
  </HModal>
</template>

Async Body

Sometimes, we want to lazy load the modal content since there could be a lot of modals in 1 page and not every modal is opened within 1 user session. More importantly, some modals contain a lot of heavy code such as 3rd party libraries, complex components,... Therefore, lazy loading the modal content can significantly help reduce the bundle size to load a page.

<HModal> supports this behavior out of the box and even has animation + loading indicator to bring the best UI/UX to the end-user!

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 SelectOption } 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 SelectOption[]

const jobTitles = [
  { value: 'product-manager', label: 'Product Manager' },
  { value: 'engineer', label: 'Engineer' },
  { value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOption[]
</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>!

This is because in <script setup>, every variable and function will be re-defined again when the component is created and is destroyed after unmount. Therefore, you must define the async body component in the global context via normal <script> instead!

Show/hide modal programmatically

When showing modal programmatically, we also pass the modal component to the open() function. DO NOT defineAsyncComponent it again as all animations won't be applied properly!

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 cannot be automatically focused while the body is loading. You must manually handle the initial focus yourself by using onMounted within the body component or using the @bodyResolved event (remember to nextTick()!).

💡 Quickly pass props to/emit data from the body component

Simply use v-bind="props" and re-emit the @dismiss and @resolve events! You can even define the emits as props with an on prefix to mark them as events for Vue compiler.

However, there's a caveat to this approach that is the @dismiss and @resolve events are originally emited by <HModal> itself which means you cannot leverage automatic Data Flow mechanism. To work-around this, you need to use the no-footer prop and fill the #body slot with the <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 SelectOption, 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 SelectOption[]

const jobTitles = [
  { value: 'product-manager', label: 'Product Manager' },
  { value: 'engineer', label: 'Engineer' },
  { value: 'data-analyst', label: 'Data Analyst' },
] as const satisfies SelectOption[]
</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
DefinePropEmit
@resolve
DefinePropEmit

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