Skip to content
RTL Support: Full
Accessibility: Full
Translations: Not Needed

Modal

A dialog that overlays the page to focus the user on a single task — a confirmation, a short form, or a contextual detail — while blocking interaction with the content behind it. Control visibility with v-model:show, and use the header, default, and footer slots for content.

Layout tokens

HLModal supports global CSS tokens for sizing defaults:

  • --hr-modal-width controls the default modal width.
  • --hr-modal-content-max-height controls scrollable body max-height.

Default

The simplest modal: a trigger button toggles show, and the default slot holds the body content.

vue
<template>
  <HLButton @click="showModal=!showModal" id="modal-basic-btn-default">Open Modal</HLButton>
  <HLModal id="example-basic-modal-default" v-model:show="showModal">
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>

Types

Set the type prop (default, info, success, warning, error) to style the modal — each renders a matching header icon and accent color. type also selects the built-in footer buttons when you don't supply a #footer slot.

vue
<template>
  <HLButton @click="showDefaultModal=!showDefaultModal" id="modal-btn-default">Default Modal</HLButton>
  <HLModal id="example-modal-default" type="default" v-model:show="showDefaultModal" :mask-closable="false">
    <template #header> Modal Header </template>
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showDefaultModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>
vue
<template>
  <HLButton @click="showInfoModal=!showInfoModal" id="modal-btn-info">Info Modal</HLButton>
  <HLModal id="example-modal-info" type="info" v-model:show="showInfoModal" :mask-closable="false">
    <template #header> Modal Header </template>
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showInfoModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>
vue
<template>
  <HLButton @click="showSuccessModal=!showSuccessModal" id="modal-btn-success">Success Modal</HLButton>
  <HLModal id="example-modal-success" type="success" v-model:show="showSuccessModal" :mask-closable="false">
    <template #header> Modal Header </template>
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showSuccessModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>
vue
<template>
  <HLButton @click="showWarningModal=!showWarningModal" id="modal-btn-warning">Warning Modal</HLButton>
  <HLModal id="example-modal-warning" type="warning" v-model:show="showWarningModal" :mask-closable="false">
    <template #header> Modal Header </template>
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showWarningModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>
vue
<template>
  <HLButton @click="showErrorModal=!showErrorModal" id="modal-btn-error">Error Modal</HLButton>
  <HLModal id="example-modal-error" type="error" v-model:show="showErrorModal" :mask-closable="false">
    <template #header> Modal Header </template>
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showErrorModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>

With Custom Header

Replace the default header with the #header slot to render custom content such as an HLHeaderLite with a title, subtitle, and icon.

vue
<template>
  <HLButton @click="showCustomHeaderModal=!showCustomHeaderModal" id="modal-custom-header-btn-default">Open Modal</HLButton>
  <HLModal id="example-custom-header-modal-default" type="default" v-model:show="showCustomHeaderModal" :showHeaderIcon="false">
    <template #header>
      <HLHeaderLite title="Modal Header" size="lg" subtitle="This is a sample subtitle" :closable="false">
        <template #header-title>
          <HLText size="2xl" :weight="'semibold'"> Modal Header with 2xl size </HLText>
        </template>
        <template #header-icons>
          <HLIcon size="lg" color="black">
            <TrendUp01Icon />
          </HLIcon>
        </template>
      </HLHeaderLite>
    </template>
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton, HLHeaderLite, HLText, HLIcon } from '@platform-ui/highrise'
  import { TrendUp01Icon } from '@gohighlevel/ghl-icons/24/outline'
  import { ref } from 'vue'
  const showCustomHeaderModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>

Show Back Button

Set showBack to render a back button in the header and handle the @back event to control navigation.

vue
<template>
  <HLButton @click="showBackModal=!showBackModal" id="modal-back-btn-default">Open Modal</HLButton>
  <HLModal
    id="example-back-modal-default"
    type="default"
    v-model:show="showBackModal"
    showBack
    @back="showBackModal=false"
    :mask-closable="false"
  >
    <template #header> Modal Header </template>
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showBackModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>

Mask Closable

When the mask is closable, the modal will be closed when the mask is clicked.

vue
<template>
  <HLButton @click="showMaskClosableModal=!showMaskClosableModal" id="modal-mask-closable-btn-default">Open Modal</HLButton>
  <HLModal id="example-mask-closable-modal-default" type="default" v-model:show="showMaskClosableModal" maskClosable>
    <template #header> Modal Header </template>
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showMaskClosableModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>

Omit the #footer slot and the modal renders a built-in footer for you. Which buttons appear — and what they say — is determined entirely by the type prop, so type controls far more than the header icon and accent colour:

typeLeft buttonRight secondaryRight primary
defaultBackCancelContinue
infoCancelContinue
successContinue
warningDiscardCancelSave Changes
errorDiscardCancelYes, Delete

The three buttons emit @left-click, @right-secondary-click, and @right-primary-click. None of them closes the modal on its own — handle the event and set show yourself.

Open each type below and press its footer buttons to see which event fires.

Event Log:

No events yet. Open a modal and press a footer button.
vue
<template>
  <HLButton id="modal-builtin-warning-btn" @click="openBuiltInFooter('warning')">Warning</HLButton>

  <!-- No #footer slot, so the built-in footer renders based on `type` -->
  <HLModal
    id="example-builtin-footer-modal"
    :type="type"
    v-model:show="show"
    @left-click="log('left-click')"
    @right-secondary-click="log('right-secondary-click')"
    @right-primary-click="() => { log('right-primary-click'); show = false }"
  >
    <template #header>Built-in footer</template>
    The footer buttons come from the <code>type</code> prop.
  </HLModal>
</template>

<script setup lang="ts">
import { HLModal, HLButton } from '@platform-ui/highrise'
import { ref } from 'vue'

const show = ref(false)
const type = ref<'default' | 'info' | 'success' | 'warning' | 'error'>('default')

const eventLog = ref<string[]>([])
const log = (event: string) => {
  eventLog.value.unshift(event)
  if (eventLog.value.length > 5) eventLog.value.pop()
}

const openBuiltInFooter = (next: typeof type.value) => {
  type.value = next
  show.value = true
}
</script>

INFO

The built-in button labels, colours, and variants are fixed per type and can't be configured. When you need different labels or actions, use the #footer slot shown below — it replaces the built-in footer entirely, and those three emits then never fire.

Use the #footer slot to render action buttons, typically via HLSectionFooter and HLSectionFooterItem. Providing this slot replaces the built-in footer.

vue
<template>
  <HLButton @click="showModal=!showModal" id="modal-custom-header-footer-btn-default">Open Modal</HLButton>
  <HLModal id="example-custom-header-footer-modal-default" type="default" v-model:show="showModal">
    <template #header> Modal Header </template>
    {{modalText}}
    <template #footer>
      <div class="p-4">
        <HLSectionFooter id="footer" :top-padding="false" :bottom-padding="false" :horizontal-padding="false">
          <HLSectionFooterItem>
            <HLButton id="cancel">Discard</HLButton>
            <HLButton id="save" color="blue" variant="primary">Ok </HLButton>
          </HLSectionFooterItem>
        </HLSectionFooter>
      </div>
    </template>
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton, HLSectionFooter, HLSectionFooterItem } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showModal = ref(false)
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>

Custom Width

width sets the modal's width in pixels. The default of 483px resolves through the --hr-modal-width CSS variable published by HLContentWrap, so you can also change the default for a whole subtree by overriding that token instead of setting the prop on every modal.

vue
<template>
  <HLButton id="modal-width-wide-btn" @click="openWidth(720)">Wide (720)</HLButton>

  <HLModal id="example-width-modal" v-model:show="show" :width="width" :show-footer="false">
    <template #header>Width: {{ width }}px</template>
    {{ modalText }}
  </HLModal>
</template>

<script setup lang="ts">
import { HLModal, HLButton } from '@platform-ui/highrise'
import { ref } from 'vue'

const show = ref(false)
const width = ref(483)

const openWidth = w => {
  width.value = w
  show.value = true
}
</script>
css
/* Or change the default width for every modal in a subtree */
.my-section {
  --hr-modal-width: 640px;
}

Lifecycle and Dismiss Events

The modal emits events across its whole lifecycle, and three props govern how it can be dismissed:

  • closeOnEsc — Esc closes the modal. @esc fires on the keypress either way.
  • maskClosable — clicking the mask closes it. @mask-click fires on every mask click either way.
  • blockScroll — locks scrolling on the page behind while the modal is open.

@before-leave@after-leave bracket the closing transition, and @after-enter fires once the modal is fully open. @close fires when the close (×) button is used.

Event Log:

No events yet. Open the modal, then try Esc, the mask, and the close button.
vue
<template>
  <HLButton id="modal-events-btn" @click="show = true">Open Modal</HLButton>

  <div>
    <p>Event Log:</p>
    <div v-for="(entry, i) in eventLog" :key="i">{{ entry }}</div>
  </div>

  <HLModal
    id="example-events-modal"
    v-model:show="show"
    :close-on-esc="closeOnEsc"
    :mask-closable="maskClosable"
    :block-scroll="blockScroll"
    :show-footer="false"
    @after-enter="log('after-enter')"
    @before-leave="log('before-leave')"
    @after-leave="log('after-leave')"
    @close="log('close')"
    @esc="log('esc')"
    @mask-click="log('mask-click')"
    @update:show="value => log(`update:show — ${value}`)"
  >
    <template #header>Lifecycle and Dismiss Events</template>
    Try Esc, the mask, and the close button.
  </HLModal>
</template>

<script setup lang="ts">
import { HLModal, HLButton } from '@platform-ui/highrise'
import { ref } from 'vue'

const show = ref(false)
const closeOnEsc = ref(true)
const maskClosable = ref(true)
const blockScroll = ref(true)

const eventLog = ref<string[]>([])
const log = (event: string) => {
  eventLog.value.unshift(event)
  if (eventLog.value.length > 6) eventLog.value.pop()
}
</script>

INFO

@esc and @mask-click fire whether or not the modal actually closes, so they're the right hook for "discard your changes?" flows — set :close-on-esc="false" and :mask-closable="false", then decide in the handler whether to close.

Focus Trap

When trapFocus is enabled (default), pressing Tab cycles through focusable elements inside the modal without escaping to the page behind it. Note that autoFocus is off by default on HLModal, so focus doesn't move into the modal until the user tabs — pass :auto-focus="true", as below, to move it on open.

vue
<template>
  <HLButton @click="showModal = true">Open Form Modal</HLButton>
  <HLModal id="form-modal" v-model:show="showModal" :trap-focus="true" :auto-focus="true" :show-header-icon="false">
    <template #header>Add Team Member</template>
    <div style="display: flex; flex-direction: column; gap: 16px; padding: 8px 0;">
      <HLFormItem label="Name">
        <HLInput v-model="name" placeholder="Enter full name" />
      </HLFormItem>
      <HLFormItem label="Email">
        <HLInput v-model="email" placeholder="Enter email address" />
      </HLFormItem>
      <HLFormItem label="Role">
        <HLSelect v-model:value="role" :options="roleOptions" placeholder="Select a role" />
      </HLFormItem>
    </div>
    <template #footer>
      <div style="display: flex; gap: 8px; justify-content: flex-end; padding: 8px;">
        <HLButton @click="showModal = false">Cancel</HLButton>
        <HLButton variant="primary" color="blue" @click="showModal = false">Save</HLButton>
      </div>
    </template>
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton, HLFormItem, HLInput, HLSelect } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showModal = ref(false)
  const name = ref('')
  const email = ref('')
  const role = ref(null)
  const roleOptions = [
    { label: 'Admin', value: 'admin' },
    { label: 'Editor', value: 'editor' },
    { label: 'Viewer', value: 'viewer' },
  ]
</script>

Note

If you're facing issues with unfocusable input elements when you have multiple instances open, try setting :trapFocus="false" on the underlying instance of the component.

Accessibility

  • Focus trapping keeps keyboard users within dialogs/drawers so they can’t tab into the page behind the overlay. Disable it only when you intentionally need the background to stay reachable (for example, nested panels) and provide guidance on how to return to the original surface.
  • When you turn trapping off, manage focus manually: move focus to the element that should be active next and offer a clear close button so users can re-enter the dialog flow without relying on a mouse.

Overlays inside a Modal

When you place an overlay component (e.g. HLSelect, HLPopover, HLDatePicker) inside an HLModal whose content is long enough to scroll, the overlay can detach from its trigger while the modal content scrolls. This happens because the overlay defaults to teleporting into <body>, which is outside the modal's scrolling container.

To keep the overlay anchored to its trigger, pass to="#<modal-id> .hr-dialog__content--text" on the overlay. This teleports the overlay's DOM into the modal's scrollable body, so trigger and overlay move together when content scrolls.

vue
<template>
  <HLButton @click="showAnchoringModal = true" id="modal-anchoring-btn">Open scrollable modal with overlays</HLButton>
  <HLModal id="example-anchoring-modal" class="example-anchoring-modal--compact" v-model:show="showAnchoringModal" :show-header-icon="false">
    <template #header>Anchoring overlays during scroll</template>
    <HLFormItem label="Role">
      <HLSelect v-model:value="anchoringRole" :options="roleOptions" placeholder="Select a role" to="#example-anchoring-modal .hr-dialog__content--text" />
    </HLFormItem>
    <HLFormItem label="Start date">
      <HLDatePicker v-model:value="anchoringDate" type="date" placeholder="Pick a date" to="#example-anchoring-modal .hr-dialog__content--text" />
    </HLFormItem>
    <HLFormItem label="Start time">
      <HLTimePicker v-model:value="anchoringTime" format="HH:mm" to="#example-anchoring-modal .hr-dialog__content--text" />
    </HLFormItem>
    {{modalText}}
    {{modalText}}
    {{modalText}}
  </HLModal>
</template>
<script setup lang="ts">
  import { HLModal, HLButton, HLFormItem, HLSelect, HLDatePicker, HLTimePicker } from '@platform-ui/highrise'
  import { ref } from 'vue'
  const showAnchoringModal = ref(false)
  const anchoringRole = ref(null)
  const anchoringDate = ref(null)
  const anchoringTime = ref(null)
  const roleOptions = [
    { label: 'Admin', value: 'admin' },
    { label: 'Editor', value: 'editor' },
    { label: 'Viewer', value: 'viewer' },
  ]
  const modalText = 'Tempore vivo desidero. Bene defungo perferendis calco avarus quas crepusculum accendo.'
</script>
<style>
/* Scope the content max-height via a class on the modal — Modal.vue binds :style internally,
   so a static style attribute on <HLModal> can be overridden. */
.example-anchoring-modal--compact .hr-dialog__content--text {
  --hr-modal-content-max-height: 320px;
}
</style>

Accessibility

  • role="dialog" and aria-modal="true" are set automatically. aria-labelledby points to the modal title, but only while showHeader is true — when you hide the header, label the dialog another way (for example aria-label via $attrs).
  • Focus is trapped inside the modal by default (trapFocus). Press Escape to close.
  • autoFocus is false by default, so opening the modal does not move focus into it. Set :auto-focus="true" for dialogs where the user should land on the first control immediately.
  • Point explanatory text to aria-describedby, especially for confirmations or forms.
  • Sync the trigger button’s aria-expanded / aria-controls (or provide another status message) so users know when the modal opens/closes.
  • Provide a stable id; the component links the header slot to the dialog automatically via ${id}-title.

Imports

ts
import { HLModal } from '@platform-ui/highrise'

Props

NameTypeDefaultDescription
idstring | undefinedhr-modal-{n} (auto)The unique identifier of the modal. When omitted, a stable hr-modal-{n} id is generated and the header title receives {id}-title wired through aria-labelledby.
autoFocusbooleanfalseAutomatically focus on the modal when opened. Off by default, so focus stays on the trigger until the user tabs.
blockScrollbooleantruePrevent background page scroll while modal is open
classNamestring | undefinedundefinedCustom class name for the modal
closeOnEscbooleantrueClose the modal when the Escape key is pressed
footerDividerbooleanfalseShow a divider above the footer area
maskClosablebooleantrueClose the modal when clicking on the mask
showbooleanfalseControl the visibility of the modal
showBackbooleanfalseShow the back button in the modal
showClosebooleantrueShow the close button in the modal
showFooterbooleantrueShow the footer section in the modal
showHeaderbooleantrueShow the header section in the modal. Requires a #header slot to render, and when false the aria-labelledby link is dropped.
showHeaderIconbooleantrueShow the icon in the header section
tostring | HTMLElement | undefinedundefinedSpecify the container to which the modal is appended
trapFocusbooleantrueTraps the focus inside the modal
type'default' | 'info' | 'success' | 'warning' | 'error''default'Type of the modal indicating its purpose. Sets the header icon, accent colour, and the built-in footer buttons.
widthnumber483 (via var(--hr-modal-width))Width of the modal in pixels. Default resolves through the --hr-modal-width CSS variable published by HLContentWrap.
zIndexnumber | undefinedundefinedz-index of the modal

Emits

NameTypeTrigger
@after-enter() => voidafter the modal has fully entered
@after-leave() => voidafter the modal has fully left
@back() => voidwhen the back button is clicked
@before-leave() => voidbefore the modal starts to leave
@close() => voidwhen the modal is closed
@esc() => voidwhen the Escape key is pressed
@left-click() => voidwhen the footer left button is clicked
@mask-click(payload: PointerEvent) => voidwhen the mask (overlay) is clicked
@right-primary-click() => voidwhen the footer right primary button is clicked
@right-secondary-click() => voidwhen the footer's right secondary button is clicked
@update:show(payload: boolean) => voidwhen the visibility of the modal is updated (show/hide toggle)

Slots

NameParametersDescription
default()The default content slot
header()The header content slot
footer()The footer content slot