Skip to content

Modal

Modal is a dialog overlay for focused user interactions and important content.

Import

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

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

<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

PropTypeDefaultDescription
modelValuebooleanundefinedControlled open state.
isOpenbooleanundefinedAlternative controlled open state.
defaultOpenbooleanfalseInitial uncontrolled open state.

ModalBackdrop Props

PropTypeDefaultDescription
variant'transparent' | 'opaque' | 'blur''opaque'Backdrop treatment.
isDismissablebooleantrueClose when pressing the backdrop.
isKeyboardDismissDisabledbooleanfalseDisable Escape dismissal.
modelValuebooleanundefinedStandalone controlled open state.
isOpenbooleanundefinedStandalone controlled open state alias.
portalContainerHTMLElement | string'body'Custom teleport target.
unstablePortalContainerHTMLElementundefinedReact-compatible custom portal target alias.

ModalContainer Props

PropTypeDefaultDescription
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

PropTypeDefaultDescription
asstringundefinedRendered heading element override.
level1 | 2 | 3 | 4 | 5 | 63React Aria heading level semantics.

Released under the Apache-2.0 License.