Modal
Modal is a dialog overlay for focused user interactions and important content.
Import
<script setup lang="ts">
import {
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
ModalTrigger,
} from '@rysinal/heroui-vue'
</script>Usage
Default
<template>
<Modal>
<Button variant="secondary">Open Modal</Button>
<ModalBackdrop>
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-default text-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" />
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</svg>
</ModalIcon>
<ModalHeading>Welcome to HeroUI</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
A beautiful, fast, and modern Vue UI library for building accessible and customizable
web applications with ease.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button class="w-full" @click="close">Continue</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</template>
<script setup>
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
} from '@rysinal/heroui-vue'
</script>
<style lang="less">
.demo-modal-dialog {
max-width: 22.5rem;
}
</style>Anatomy
<template>
<Modal>
<Button>Open Modal</Button>
<ModalBackdrop>
<ModalContainer>
<ModalDialog>
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon />
<ModalHeading>Title</ModalHeading>
</ModalHeader>
<ModalBody>Dialog content</ModalBody>
<ModalFooter />
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</template>Placement
<template>
<div class="flex flex-wrap gap-4">
<Modal v-for="placement in placements" :key="placement">
<Button variant="secondary">
{{ placementLabel(placement) }}
</Button>
<ModalBackdrop>
<ModalContainer :placement="placement">
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-default text-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" />
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</svg>
</ModalIcon>
<ModalHeading>Placement: {{ placementLabel(placement) }}</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
This modal uses the <code>{{ placement }}</code> placement option. Try different
placements to see how the modal positions itself on the screen.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button class="w-full" @click="close">Continue</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</div>
</template>
<script setup lang="ts">
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
type ModalPlacement,
} from '@rysinal/heroui-vue'
const placements: ModalPlacement[] = ['auto', 'top', 'center', 'bottom']
const placementLabel = (placement: ModalPlacement) =>
placement.charAt(0).toUpperCase() + placement.slice(1)
</script>
<style lang="less">
.demo-modal-dialog {
max-width: 22.5rem;
}
</style>Backdrop Variants
<template>
<div class="flex flex-wrap gap-4">
<Modal v-for="variant in variants" :key="variant">
<Button variant="secondary">
{{ variantLabel(variant) }}
</Button>
<ModalBackdrop :variant="variant">
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-default text-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" />
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</svg>
</ModalIcon>
<ModalHeading>Backdrop: {{ variantLabel(variant) }}</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
This modal uses the <code>{{ variant }}</code> backdrop variant. Compare the
different visual effects: opaque provides full opacity, blur adds a backdrop
filter, and transparent removes the background.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button class="w-full" @click="close">Continue</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</div>
</template>
<script setup lang="ts">
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
type ModalBackdropVariant,
} from '@rysinal/heroui-vue'
const variants: ModalBackdropVariant[] = ['opaque', 'blur', 'transparent']
const variantLabel = (variant: ModalBackdropVariant) =>
variant.charAt(0).toUpperCase() + variant.slice(1)
</script>
<style lang="less">
.demo-modal-dialog {
max-width: 22.5rem;
}
</style>Sizes
<template>
<div class="flex flex-wrap gap-4">
<Modal v-for="size in sizes" :key="size">
<Button variant="secondary">
{{ sizeLabel(size) }}
</Button>
<ModalBackdrop>
<ModalContainer :size="size">
<ModalDialog>
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-default text-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" />
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</svg>
</ModalIcon>
<ModalHeading>Size: {{ sizeLabel(size) }}</ModalHeading>
</ModalHeader>
<ModalBody>
<p v-if="size === 'cover'">
This modal uses the <code>cover</code> size variant. It spans the full
screen with margins: 16px on mobile and 40px on desktop. Maintains rounded
corners and standard padding. Perfect for cover-style content that needs
maximum width while preserving modal aesthetics.
</p>
<p v-else-if="size === 'full'">
This modal uses the <code>full</code> size variant. It occupies the entire viewport
without any margins, rounded corners, or shadows, creating a true fullscreen
experience. Ideal for immersive content or full-page interactions.
</p>
<p v-else>
This modal uses the <code>{{ size }}</code> size variant. On mobile devices, all
sizes adapt to near full-width for optimal viewing. On desktop, each size
provides a different maximum width to suit various content needs.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="secondary" @click="close">Cancel</Button>
<Button @click="close">Confirm</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</div>
</template>
<script setup lang="ts">
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
type ModalSize,
} from '@rysinal/heroui-vue'
const sizes: ModalSize[] = ['xs', 'sm', 'md', 'lg', 'cover', 'full']
const sizeLabel = (size: ModalSize) => size.charAt(0).toUpperCase() + size.slice(1)
</script>Custom Backdrop
<template>
<Modal>
<Button variant="secondary">Custom Backdrop</Button>
<ModalBackdrop
class="bg-linear-to-t from-black/80 via-black/40 to-transparent dark:from-zinc-800/80 dark:via-zinc-800/40"
variant="blur"
>
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalHeader class="demo-modal-premium-header">
<ModalIcon class="bg-accent-soft text-accent-soft-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9.9 10.8 8 17l-1.9-6.2L0 9l6.1-1.8L8 1l1.9 6.2L16 9l-6.1 1.8Z" />
<path d="M18 16.5 17 20l-1-3.5-3.5-1 3.5-1 1-3.5 1 3.5 3.5 1-3.5 1Z" />
</svg>
</ModalIcon>
<ModalHeading>Premium Backdrop</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
This backdrop features a sophisticated gradient that transitions from a dark color
at the bottom to complete transparency at the top, combined with a smooth blur
effect. The gradient automatically adapts its intensity for optimal contrast in both
light and dark modes.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }" class="demo-modal-premium-footer">
<Button class="w-full" @click="close">Amazing!</Button>
<Button class="w-full" variant="secondary" @click="close">Close</Button>
</ModalFooter>
<ModalCloseTrigger />
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</template>
<script setup>
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
} from '@rysinal/heroui-vue'
</script>
<style lang="less">
.demo-modal-dialog {
max-width: 22.5rem;
}
.demo-modal-premium-header {
align-items: center;
text-align: center;
}
.demo-modal-premium-footer {
flex-direction: column-reverse;
}
</style>Dismiss Behavior
isDismissable
Controls whether the modal can be dismissed by clicking the overlay backdrop. Defaults to true. Set to false to require explicit close action.
isKeyboardDismissDisabled
Controls whether the ESC key can dismiss the modal. When set to true, the ESC key will be disabled and users must use explicit close actions.
<template>
<div class="flex max-w-sm flex-col gap-6 demo-modal-dismiss">
<section class="flex flex-col gap-2 demo-modal-section">
<h3 class="text-lg font-semibold demo-modal-section-title">isDismissable</h3>
<p class="demo-modal-section-copy">
Controls whether the modal can be dismissed by clicking the overlay backdrop. Defaults to
<code>true</code>. Set to <code>false</code> to require explicit close action.
</p>
<Modal>
<Button variant="secondary">Open Modal</Button>
<ModalBackdrop :is-dismissable="false">
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-default text-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
</ModalIcon>
<ModalHeading>isDismissable = false</ModalHeading>
<p class="demo-modal-header-copy">
Clicking the backdrop won't close this modal
</p>
</ModalHeader>
<ModalBody>
<p>
Try clicking outside this modal on the overlay - it won't close. You must use
the close button or press ESC to dismiss it.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button class="w-full" @click="close">Close</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</section>
<section class="flex flex-col gap-2 demo-modal-section">
<h3 class="text-lg font-semibold demo-modal-section-title">isKeyboardDismissDisabled</h3>
<p class="demo-modal-section-copy">
Controls whether the ESC key can dismiss the modal. When set to <code>true</code>, the ESC
key will be disabled and users must use explicit close actions.
</p>
<Modal>
<Button variant="secondary">Open Modal</Button>
<ModalBackdrop is-keyboard-dismiss-disabled>
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-default text-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
</ModalIcon>
<ModalHeading>isKeyboardDismissDisabled = true</ModalHeading>
<p class="demo-modal-header-copy">ESC key is disabled</p>
</ModalHeader>
<ModalBody>
<p>
Press ESC - nothing happens. You must use the close button or click the overlay
backdrop to dismiss this modal.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button class="w-full" @click="close">Close</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</section>
</div>
</template>
<script setup>
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
} from '@rysinal/heroui-vue'
</script>
<style lang="less">
.demo-modal-dismiss .demo-modal-section-title,
.demo-modal-dismiss .demo-modal-section-copy,
.demo-modal-header-copy {
margin: 0;
}
.demo-modal-section-copy {
font-size: 0.875rem;
color: var(--muted);
}
.demo-modal-header-copy {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--muted);
}
.demo-modal-dialog {
max-width: 22.5rem;
}
</style>Close Methods
Using a close target
Add data-modal-close="true" to an element inside the dialog to close the modal automatically when it is clicked.
Using dialog slot props
Access the close method from the dialog slot props when custom logic is needed before closing.
<template>
<div class="demo-modal-close-methods">
<section class="demo-modal-section">
<h3 class="demo-modal-section-title">Using a close target</h3>
<p class="demo-modal-section-copy">
Add <code>data-modal-close="true"</code> to an element inside the dialog to close the modal
automatically when it is clicked.
</p>
<Modal>
<Button variant="secondary">Open Modal</Button>
<ModalBackdrop>
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalHeader>
<ModalIcon class="bg-accent-soft text-accent-soft-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</ModalIcon>
<ModalHeading>Using a close target</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
Click either button below. Both have <code>data-modal-close="true"</code> and
will close the modal automatically.
</p>
</ModalBody>
<ModalFooter>
<Button data-modal-close="true" variant="secondary">Cancel</Button>
<Button data-modal-close="true">Confirm</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</section>
<section class="demo-modal-section">
<h3 class="demo-modal-section-title">Using dialog slot props</h3>
<p class="demo-modal-section-copy">
Access the <code>close</code> method from the dialog slot props when custom logic is needed
before closing.
</p>
<Modal>
<Button variant="secondary">Open Modal</Button>
<ModalBackdrop>
<ModalContainer>
<ModalDialog v-slot="{ close }" class="demo-modal-dialog">
<ModalHeader>
<ModalIcon class="bg-success-soft text-success-soft-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6 9 17l-5-5" />
</svg>
</ModalIcon>
<ModalHeading>Using dialog slot props</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
The footer buttons call the <code>close</code> method from slot props. You can add
validation or other logic before calling it.
</p>
</ModalBody>
<ModalFooter>
<Button variant="secondary" @click="close">Cancel</Button>
<Button @click="close">Confirm</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</section>
</div>
</template>
<script setup>
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
} from '@rysinal/heroui-vue'
</script>
<style lang="less">
.demo-modal-close-methods {
display: flex;
max-width: 42rem;
flex-direction: column;
gap: 2rem;
}
.demo-modal-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.demo-modal-close-methods .demo-modal-section-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--foreground);
}
.demo-modal-section-copy {
margin: 0;
font-size: 0.875rem;
color: var(--muted);
}
.demo-modal-dialog {
max-width: 22.5rem;
}
</style>Scroll Behavior
<template>
<div class="demo-modal-scroll">
<RadioGroup v-model="scroll" class="demo-modal-scroll-options" orientation="horizontal">
<Radio value="inside">
<Label>Inside</Label>
</Radio>
<Radio value="outside">
<Label>Outside</Label>
</Radio>
</RadioGroup>
<Modal>
<Button variant="secondary">
Open Modal ({{ scrollLabel }})
</Button>
<ModalBackdrop>
<ModalContainer :scroll="scroll">
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalHeading>Scroll: {{ scrollLabel }}</ModalHeading>
<p class="demo-modal-scroll-copy">
Compare scroll behaviors - inside keeps content scrollable within the modal,
outside allows page scrolling
</p>
</ModalHeader>
<ModalBody>
<p v-for="index in 30" :key="index" class="demo-modal-scroll-paragraph">
Paragraph {{ index }}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet
hendrerit risus, sed porttitor quam.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="secondary" @click="close">Cancel</Button>
<Button @click="close">Confirm</Button>
</ModalFooter>
<ModalCloseTrigger />
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
Button,
Label,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
Radio,
RadioGroup,
type ModalScroll,
} from '@rysinal/heroui-vue'
const scroll = ref<ModalScroll>('inside')
const scrollLabel = computed(() => scroll.value.charAt(0).toUpperCase() + scroll.value.slice(1))
</script>
<style lang="less">
.demo-modal-scroll {
display: flex;
flex-direction: column;
gap: 1rem;
}
.demo-modal-scroll-options {
flex-direction: row;
}
.demo-modal-scroll-copy {
margin: 0;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--muted);
}
.demo-modal-scroll-paragraph {
margin: 0 0 0.75rem;
}
.demo-modal-dialog {
max-width: 22.5rem;
}
</style>Controlled State
With ref()
Control the modal using Vue's ref for simple external state management.
Status: closed
With small state helpers
A lightweight state object gives the same convenience methods as an overlay-state helper.
Status: closed
<template>
<div class="demo-modal-controlled">
<section class="demo-modal-section">
<h3 class="demo-modal-section-title">With ref()</h3>
<p class="demo-modal-section-copy">
Control the modal using Vue's <code>ref</code> for simple external state management.
</p>
<div class="demo-modal-state-card">
<p class="demo-modal-state">
Status:
<span>{{ isOpen ? 'open' : 'closed' }}</span>
</p>
<div class="flex gap-2">
<Button size="sm" variant="secondary" @click="isOpen = true">Open Modal</Button>
<Button size="sm" variant="tertiary" @click="isOpen = !isOpen">Toggle</Button>
</div>
</div>
<ModalBackdrop v-model:is-open="isOpen">
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-accent-soft text-accent-soft-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6 9 17l-5-5" />
</svg>
</ModalIcon>
<ModalHeading>Controlled with ref()</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
This modal is controlled by a Vue <code>ref</code>. Pass <code>isOpen</code> with
<code>update:isOpen</code> or use <code>v-model:is-open</code>.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="secondary" @click="close">Cancel</Button>
<Button @click="close">Confirm</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</section>
<section class="demo-modal-section">
<h3 class="demo-modal-section-title">With small state helpers</h3>
<p class="demo-modal-section-copy">
A lightweight state object gives the same convenience methods as an overlay-state helper.
</p>
<div class="demo-modal-state-card">
<p class="demo-modal-state">
Status:
<span>{{ overlay.isOpen.value ? 'open' : 'closed' }}</span>
</p>
<div class="flex gap-2">
<Button size="sm" variant="secondary" @click="overlay.open">Open Modal</Button>
<Button size="sm" variant="tertiary" @click="overlay.toggle">Toggle</Button>
</div>
</div>
<ModalBackdrop :is-open="overlay.isOpen.value" @open-change="overlay.setOpen">
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-success-soft text-success-soft-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 6 9 17l-5-5" />
</svg>
</ModalIcon>
<ModalHeading>Controlled with helpers</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
The helper object exposes <code>open()</code>, <code>close()</code>,
<code>toggle()</code>, and <code>setOpen()</code> methods.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="secondary" @click="close">Cancel</Button>
<Button @click="close">Confirm</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
Button,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
} from '@rysinal/heroui-vue'
const isOpen = ref(false)
const helperOpen = ref(false)
const overlay = {
close: () => {
helperOpen.value = false
},
isOpen: helperOpen,
open: () => {
helperOpen.value = true
},
setOpen: (value: boolean) => {
helperOpen.value = value
},
toggle: () => {
helperOpen.value = !helperOpen.value
},
}
</script>
<style lang="less">
.demo-modal-controlled {
display: flex;
max-width: 28rem;
flex-direction: column;
gap: 2rem;
}
.demo-modal-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.demo-modal-controlled .demo-modal-section-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--foreground);
}
.demo-modal-section-copy {
margin: 0;
font-size: 0.875rem;
line-height: 1.625;
color: var(--muted);
}
.demo-modal-state-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
border-radius: 1rem;
background: var(--surface);
padding: 1rem;
box-shadow: var(--shadow-sm);
}
.demo-modal-state {
margin: 0;
font-size: 0.75rem;
color: var(--muted);
span {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 600;
color: var(--foreground);
}
}
.demo-modal-dialog {
max-width: 22.5rem;
}
</style>With Form
<template>
<Modal>
<Button variant="secondary">Open Contact Form</Button>
<ModalBackdrop>
<ModalContainer placement="auto">
<ModalDialog class="demo-modal-form-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-accent-soft text-accent-soft-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
</ModalIcon>
<ModalHeading>Contact Us</ModalHeading>
<p class="demo-modal-form-copy">
Fill out the form below and we'll get back to you. The modal adapts automatically
when the keyboard appears on mobile.
</p>
</ModalHeader>
<ModalBody class="demo-modal-form-body">
<Surface>
<form id="demo-modal-contact-form" class="demo-modal-form" @submit.prevent>
<TextField class="w-full" full-width is-required label="Name" name="name" placeholder="Enter your name" />
<TextField class="w-full" full-width is-required label="Email" name="email" placeholder="Enter your email" type="email" />
<TextField class="w-full" full-width label="Phone" name="phone" placeholder="Enter your phone number" type="tel" />
<TextField class="w-full" full-width label="Company" name="company" placeholder="Enter your company name" />
<TextField class="w-full" full-width is-required label="Message" name="message" placeholder="Enter your message" />
</form>
</Surface>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="secondary" @click="close">Cancel</Button>
<Button @click="submitForm(close)">Send Message</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</template>
<script setup>
/* global document, HTMLFormElement */
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
Surface,
TextField,
} from '@rysinal/heroui-vue'
const submitForm = (close) => {
const form = document.getElementById('demo-modal-contact-form')
if (!(form instanceof HTMLFormElement)) return
if (form.reportValidity()) close()
}
</script>
<style lang="less">
.demo-modal-form-dialog {
max-width: 28rem;
}
.demo-modal-form-copy {
margin: 0.375rem 0 0;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--muted);
}
.demo-modal-form-body {
padding: 1.5rem;
}
.demo-modal-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>Custom Trigger
Settings
Manage your preferences
<template>
<Modal>
<ModalTrigger class="demo-modal-card-trigger">
<div class="demo-modal-card-icon">
<svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.72l.15-.1a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</div>
<div class="demo-modal-card-copy">
<p>Settings</p>
<p>Manage your preferences</p>
</div>
</ModalTrigger>
<ModalBackdrop>
<ModalContainer>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-accent-soft text-accent-soft-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.51a2 2 0 0 1 1-1.72l.15-.1a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z" />
<circle cx="12" cy="12" r="3" />
</svg>
</ModalIcon>
<ModalHeading>Settings</ModalHeading>
</ModalHeader>
<ModalBody>
<p>
Use <code>ModalTrigger</code> to create custom trigger elements beyond standard
buttons. This example shows a card-style trigger with icon and descriptive text.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="secondary" @click="close">Cancel</Button>
<Button @click="close">Save</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</template>
<script setup>
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
ModalTrigger,
} from '@rysinal/heroui-vue'
</script>
<style lang="less">
.demo-modal-card-trigger {
display: flex;
align-items: center;
gap: 0.75rem;
border-radius: 1rem;
background: var(--surface);
padding: 1rem;
box-shadow: var(--shadow-xs);
user-select: none;
&:hover {
background: var(--surface-secondary);
}
}
.demo-modal-card-icon {
display: flex;
width: 3rem;
height: 3rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
background: var(--accent-soft);
color: var(--accent-soft-foreground);
}
.demo-modal-card-copy {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.125rem;
p {
margin: 0;
}
p:first-child {
font-size: 0.875rem;
font-weight: 600;
color: var(--foreground);
}
p:last-child {
font-size: 0.75rem;
color: var(--muted);
}
}
.demo-modal-dialog {
max-width: 22.5rem;
}
</style>Custom Animations
<template>
<div class="flex flex-wrap gap-4">
<Modal>
<Button variant="secondary">Kinematic Scale</Button>
<ModalBackdrop
class="demo-modal-animation-backdrop demo-modal-animation-backdrop--scale"
>
<ModalContainer
class="demo-modal-animation-container demo-modal-animation-container--scale"
>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-default text-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9.9 10.8 8 17l-1.9-6.2L0 9l6.1-1.8L8 1l1.9 6.2L16 9l-6.1 1.8Z" />
<path d="M18 16.5 17 20l-1-3.5-3.5-1 3.5-1 1-3.5 1 3.5 3.5 1-3.5 1Z" />
</svg>
</ModalIcon>
<ModalHeading>Kinematic Scale Animation</ModalHeading>
</ModalHeader>
<ModalBody>
<p class="demo-modal-animation-copy">
Physics-based elastic scaling. Simulates a high-damping spring system with fast
transient response and prolonged settling time. Ideal for Modals and Popovers.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="tertiary" @click="close">Close</Button>
<Button @click="close">Try Again</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
<Modal>
<Button variant="secondary">Fluid Slide</Button>
<ModalBackdrop
class="demo-modal-animation-backdrop demo-modal-animation-backdrop--slide"
>
<ModalContainer
class="demo-modal-animation-container demo-modal-animation-container--slide"
>
<ModalDialog class="demo-modal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalIcon class="bg-default text-foreground">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 19V5" />
<path d="m5 12 7-7 7 7" />
<path d="M5 22h14" />
</svg>
</ModalIcon>
<ModalHeading>Fluid Slide Animation</ModalHeading>
</ModalHeader>
<ModalBody>
<p class="demo-modal-animation-copy">
Simulates movement through a medium with fluid resistance. Eliminates mechanical
linearity for a natural, grounded feel. Perfect for Bottom Sheets or Toasts.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="tertiary" @click="close">Close</Button>
<Button @click="close">Try Again</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</div>
</template>
<script setup>
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
ModalIcon,
} from '@rysinal/heroui-vue'
</script>
<style lang="less">
.demo-modal-dialog {
max-width: 22.5rem;
}
.demo-modal-animation-copy {
margin-top: 0.25rem;
}
@media (prefers-reduced-motion: no-preference) {
.modal__backdrop.demo-modal-animation-backdrop--scale[data-entering="true"] {
animation: demo-modal-backdrop-in 400ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.modal__backdrop.demo-modal-animation-backdrop--scale[data-exiting="true"] {
animation: demo-modal-backdrop-out 200ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
.modal__container.demo-modal-animation-container--scale[data-entering="true"] {
animation: demo-modal-scale-in 400ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.modal__container.demo-modal-animation-container--scale[data-exiting="true"] {
animation: demo-modal-scale-out 200ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
.modal__backdrop.demo-modal-animation-backdrop--slide[data-entering="true"] {
animation: demo-modal-backdrop-in 500ms cubic-bezier(0.25, 1, 0.5, 1) both;
}
.modal__backdrop.demo-modal-animation-backdrop--slide[data-exiting="true"] {
animation: demo-modal-backdrop-out 200ms cubic-bezier(0.5, 0, 0.75, 0) both;
}
.modal__container.demo-modal-animation-container--slide[data-entering="true"] {
animation: demo-modal-slide-in 500ms cubic-bezier(0.25, 1, 0.5, 1) both;
}
.modal__container.demo-modal-animation-container--slide[data-exiting="true"] {
animation: demo-modal-slide-out 200ms cubic-bezier(0.5, 0, 0.75, 0) both;
}
}
@keyframes demo-modal-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes demo-modal-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes demo-modal-scale-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes demo-modal-scale-out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes demo-modal-slide-in {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes demo-modal-slide-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(0.5rem);
}
}
</style>Custom Portal
Render modals inside a custom container instead of document.body.
Apply transform: translate(0) to the container to create a new stacking context.
<template>
<div class="demo-modal-portal">
<div>
<p class="demo-modal-portal-copy">
Render modals inside a custom container instead of <code>document.body</code>.
</p>
<p class="demo-modal-portal-muted">
Apply <code>transform: translate(0)</code> to the container to create a new stacking
context.
</p>
</div>
<div ref="portalContainer" class="demo-modal-portal-frame">
<Modal v-if="portalContainer">
<Button>Open Modal</Button>
<ModalBackdrop class="demo-modal-portal-backdrop" :unstable-portal-container="portalContainer">
<ModalContainer class="demo-modal-portal-container">
<ModalDialog class="demo-modal-portal-dialog">
<ModalCloseTrigger />
<ModalHeader>
<ModalHeading>Custom Portal</ModalHeading>
</ModalHeader>
<ModalBody>
<p v-for="index in 3" :key="index" class="demo-modal-portal-paragraph">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</p>
</ModalBody>
<ModalFooter v-slot="{ close }">
<Button variant="secondary" @click="close">Close</Button>
</ModalFooter>
</ModalDialog>
</ModalContainer>
</ModalBackdrop>
</Modal>
</div>
</div>
</template>
<script setup lang="ts">
/* global HTMLElement */
import { useTemplateRef } from 'vue'
import {
Button,
Modal,
ModalBackdrop,
ModalBody,
ModalCloseTrigger,
ModalContainer,
ModalDialog,
ModalFooter,
ModalHeader,
ModalHeading,
} from '@rysinal/heroui-vue'
const portalContainer = useTemplateRef<HTMLElement>('portalContainer')
</script>
<style lang="less">
.demo-modal-portal {
display: flex;
flex-direction: column;
gap: 1rem;
}
.demo-modal-portal-copy,
.demo-modal-portal-muted,
.demo-modal-portal-paragraph {
margin: 0;
font-size: 0.875rem;
}
.demo-modal-portal-muted,
.demo-modal-portal-paragraph {
color: var(--muted);
}
.demo-modal-portal-frame {
position: relative;
display: flex;
height: 23.75rem;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--muted) 20%, transparent);
transform: translate(0);
}
.demo-modal-portal-backdrop {
height: 100%;
}
.demo-modal-portal-container,
.demo-modal-portal-dialog {
height: 100%;
max-height: 100%;
}
.demo-modal-portal-dialog {
max-width: 28rem;
}
</style>API
Modal Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | boolean | undefined | Controlled open state. |
isOpen | boolean | undefined | Alternative controlled open state. |
defaultOpen | boolean | false | Initial uncontrolled open state. |
ModalBackdrop Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'transparent' | 'opaque' | 'blur' | 'opaque' | Backdrop treatment. |
isDismissable | boolean | true | Close when pressing the backdrop. |
isKeyboardDismissDisabled | boolean | false | Disable Escape dismissal. |
modelValue | boolean | undefined | Standalone controlled open state. |
isOpen | boolean | undefined | Standalone controlled open state alias. |
portalContainer | HTMLElement | string | 'body' | Custom teleport target. |
unstablePortalContainer | HTMLElement | undefined | React-compatible custom portal target alias. |
ModalContainer Props
| Prop | Type | Default | Description |
|---|---|---|---|
placement | 'auto' | 'top' | 'center' | 'bottom' | 'auto' | Dialog placement. |
scroll | 'inside' | 'outside' | 'inside' | Scroll behavior. |
size | 'xs' | 'sm' | 'md' | 'lg' | 'cover' | 'full' | 'md' | Dialog width or viewport preset. |
ModalHeading Props
| Prop | Type | Default | Description |
|---|---|---|---|
as | string | undefined | Rendered heading element override. |
level | 1 | 2 | 3 | 4 | 5 | 6 | 3 | React Aria heading level semantics. |