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-widthcontrols the default modal width.--hr-modal-content-max-heightcontrols scrollable body max-height.
Default
The simplest modal: a trigger button toggles show, and the default slot holds the body content.
<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.
<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><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><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><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><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.
<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.
<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.
<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>Built-in Footer
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:
type | Left button | Right secondary | Right primary |
|---|---|---|---|
default | Back | Cancel | Continue |
info | — | Cancel | Continue |
success | — | — | Continue |
warning | Discard | Cancel | Save Changes |
error | Discard | Cancel | Yes, 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:
<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.
Custom Footer
Use the #footer slot to render action buttons, typically via HLSectionFooter and HLSectionFooterItem. Providing this slot replaces the built-in footer.
<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.
<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>/* 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.@escfires on the keypress either way.maskClosable— clicking the mask closes it.@mask-clickfires 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:
<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.
<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.
<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"andaria-modal="true"are set automatically.aria-labelledbypoints to the modal title, but only whileshowHeaderistrue— when you hide the header, label the dialog another way (for examplearia-labelvia$attrs).- Focus is trapped inside the modal by default (
trapFocus). PressEscapeto close. autoFocusisfalseby 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
import { HLModal } from '@platform-ui/highrise'Props
| Name | Type | Default | Description |
|---|---|---|---|
| id | string | undefined | hr-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. |
| autoFocus | boolean | false | Automatically focus on the modal when opened. Off by default, so focus stays on the trigger until the user tabs. |
| blockScroll | boolean | true | Prevent background page scroll while modal is open |
| className | string | undefined | undefined | Custom class name for the modal |
| closeOnEsc | boolean | true | Close the modal when the Escape key is pressed |
| footerDivider | boolean | false | Show a divider above the footer area |
| maskClosable | boolean | true | Close the modal when clicking on the mask |
| show | boolean | false | Control the visibility of the modal |
| showBack | boolean | false | Show the back button in the modal |
| showClose | boolean | true | Show the close button in the modal |
| showFooter | boolean | true | Show the footer section in the modal |
| showHeader | boolean | true | Show the header section in the modal. Requires a #header slot to render, and when false the aria-labelledby link is dropped. |
| showHeaderIcon | boolean | true | Show the icon in the header section |
| to | string | HTMLElement | undefined | undefined | Specify the container to which the modal is appended |
| trapFocus | boolean | true | Traps 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. |
| width | number | 483 (via var(--hr-modal-width)) | Width of the modal in pixels. Default resolves through the --hr-modal-width CSS variable published by HLContentWrap. |
| zIndex | number | undefined | undefined | z-index of the modal |
Emits
| Name | Type | Trigger |
|---|---|---|
@after-enter | () => void | after the modal has fully entered |
@after-leave | () => void | after the modal has fully left |
@back | () => void | when the back button is clicked |
@before-leave | () => void | before the modal starts to leave |
@close | () => void | when the modal is closed |
@esc | () => void | when the Escape key is pressed |
@left-click | () => void | when the footer left button is clicked |
@mask-click | (payload: PointerEvent) => void | when the mask (overlay) is clicked |
@right-primary-click | () => void | when the footer right primary button is clicked |
@right-secondary-click | () => void | when the footer's right secondary button is clicked |
@update:show | (payload: boolean) => void | when the visibility of the modal is updated (show/hide toggle) |
Slots
| Name | Parameters | Description |
|---|---|---|
| default | () | The default content slot |
| header | () | The header content slot |
| footer | () | The footer content slot |