Modal overlay with portal, backdrop, and focus management.
Introduction
Modal is a lower-level overlay primitive. It renders children in front of a backdrop, locks page scroll by default, and manages focus while open.
Use Modal when you need a custom content shell (often with Card). Prefer useDialogAction for confirm/cancel flows with a standard footer.
Modal vs blocking dialog behavior: Modal itself is a generic overlay—it does not require user input to dismiss unless you configure it that way. For blocking dialogs that demand an explicit decision (confirm delete, accept terms), combine Modal with Card for structured title and actions, and set persistent so Escape and backdrop clicks do not close the overlay until the user chooses a footer action. For non-blocking overlays or drawers, leave persistent false so users can dismiss quickly.
Unlike Snackbar, Modal interrupts interaction with the page below through backdrop and focus trapping.
Import
import { Modal } from "@bridge-ui/vue/Components/Modal";import { Modal } from "@bridge-ui/react/Components/Modal";Basic usage
Pair a trigger such as a Button with a controlled Modal. Place content such as a Card inside the dialog panel.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
const open = ref(false);
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Open modal</Button>
<Modal v-model="open">
<Card title="Confirm action"> Are you sure you want to continue? </Card>
</Modal>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
export default function ModalBasic() {
const [open, setOpen] = useState(false);
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Open modal</Button>
<Modal show={open} onShowChange={setOpen}>
<Card title="Confirm action">Are you sure you want to continue?</Card>
</Modal>
</div>
);
}
Sizes
Use the size prop on Modal to set max width from the sm breakpoint up. Available sizes: sm, md (default), lg, xl, and full.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
type Size = "lg" | "md" | "sm" | "xl" | "full";
const size = ref<null | Size>(null);
const open = ref(false);
const openSize = (value: Size) => {
size.value = value;
open.value = true;
};
const onShowChange = (show: boolean) => {
open.value = show;
if (!show) size.value = null;
};
</script>
<template>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" v-on:click="openSize('sm')">
sm
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('md')">
md
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('lg')">
lg
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('xl')">
xl
</Button>
<Button size="sm" variant="outline" v-on:click="openSize('full')">
full
</Button>
<Modal
v-if="size"
:size="size"
:model-value="open"
v-on:show-change="onShowChange"
>
<Card :title="`size="${size}"`">
Use the `size` prop to control the max width from the `sm` breakpoint
up.
</Card>
</Modal>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
type Size = "lg" | "md" | "sm" | "xl" | "full";
export default function ModalSizes() {
const [size, setSize] = useState<null | Size>(null);
return (
<div className="flex flex-wrap gap-2">
{(["lg", "md", "sm", "xl", "full"] as const).map((value) => (
<Button
size="sm"
key={value}
variant="outline"
onClick={() => setSize(value)}
>
{value}
</Button>
))}
<Modal
show={!!size}
size={size ?? "md"}
onShowChange={(show) => !show && setSize(null)}
>
<Card title={`size="${size}"`}>
Use the `size` prop to control the max width from the `sm` breakpoint
up.
</Card>
</Modal>
</div>
);
}
Persistent
When persistent is true, Escape and backdrop clicks do not close the modal. Provide an explicit close action inside the content.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
const open = ref(false);
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Open persistent modal</Button>
<Modal persistent v-model="open">
<Card title="Persistent modal">
Clicking the backdrop or pressing Escape has no effect. Close with the
button below.
<div class="mt-4">
<Button size="sm" v-on:click="open = false">Close</Button>
</div>
</Card>
</Modal>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
export default function ModalPersistent() {
const [open, setOpen] = useState(false);
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Open persistent modal</Button>
<Modal persistent show={open} onShowChange={setOpen}>
<Card title="Persistent modal">
Clicking the backdrop or pressing Escape has no effect. Close with the
button below.
<div className="mt-4">
<Button size="sm" onClick={() => setOpen(false)}>
Close
</Button>
</div>
</Card>
</Modal>
</div>
);
}
Card composition
Combine Modal with Card for structured dialogs. Use Card slots for title, body, and footer actions. Additional Modal props such as blur, size, and transition customize the overlay experience.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
const open = ref(false);
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Edit profile</Button>
<Modal blur="md" size="lg" v-model="open" transition="slide-up">
<Card
borderless
shadow="lg"
rounded="2xl"
padding="large"
variant="elevated"
title="Edit profile"
>
Large modal with Card title, body, and footer slots.
<template #footer>
<div class="flex justify-end gap-2">
<Button
size="sm"
color="error"
variant="flat"
v-on:click="open = false"
>
Discard
</Button>
<Button size="sm" color="primary" v-on:click="open = false"
>Save</Button
>
</div>
</template>
</Card>
</Modal>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
export default function ModalCardComposition() {
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Edit profile</Button>
<Modal
blur="md"
size="lg"
show={open}
transition="slide-up"
onShowChange={setOpen}
>
<Card
borderless
shadow="lg"
rounded="2xl"
padding="large"
variant="elevated"
title="Edit profile"
slots={{
footer: (
<div className="flex justify-end gap-2">
<Button size="sm" color="error" variant="flat" onClick={close}>
Discard
</Button>
<Button size="sm" color="primary" onClick={close}>
Save
</Button>
</div>
),
}}
>
Large modal with Card title, body, and footer slots.
</Card>
</Modal>
</div>
);
}
Align
Use the align prop to position the panel on all breakpoints. Available values include middle-center (default), top-start, bottom-end, and others.
align now applies on every viewport. Mobile no longer forces a bottom sheet. Use useBreakpoint to restore bottom-sheet-on-mobile behavior.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
type Align =
| "top-end"
| "top-start"
| "bottom-end"
| "middle-end"
| "top-center"
| "bottom-start"
| "middle-start"
| "bottom-center"
| "middle-center";
const alignOpen = ref<null | Align>(null);
const modalOpen = ref(false);
const openAlign = (align: Align) => {
alignOpen.value = align;
modalOpen.value = true;
};
const closeModal = (open: boolean) => {
modalOpen.value = open;
if (!open) alignOpen.value = null;
};
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" v-on:click="openAlign('top-start')">
top-start
</Button>
<Button size="sm" variant="outline" v-on:click="openAlign('top-center')">
top-center
</Button>
<Button size="sm" variant="outline" v-on:click="openAlign('top-end')">
top-end
</Button>
<Button
size="sm"
variant="outline"
v-on:click="openAlign('middle-start')"
>
middle-start
</Button>
<Button
size="sm"
variant="outline"
v-on:click="openAlign('middle-center')"
>
middle-center
</Button>
<Button size="sm" variant="outline" v-on:click="openAlign('middle-end')">
middle-end
</Button>
<Button
size="sm"
variant="outline"
v-on:click="openAlign('bottom-start')"
>
bottom-start
</Button>
<Button
size="sm"
variant="outline"
v-on:click="openAlign('bottom-center')"
>
bottom-center
</Button>
<Button size="sm" variant="outline" v-on:click="openAlign('bottom-end')">
bottom-end
</Button>
</div>
<Modal
v-if="alignOpen"
:align="alignOpen"
:model-value="modalOpen"
v-on:show-change="closeModal"
>
<Card :title="`align="${alignOpen}"`">
Panel position on all breakpoints. Use useBreakpoint for a different
align per viewport.
</Card>
</Modal>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
type Align =
| "top-end"
| "top-start"
| "bottom-end"
| "middle-end"
| "top-center"
| "bottom-start"
| "middle-start"
| "bottom-center"
| "middle-center";
const ModalAlign = () => {
const [alignOpen, setAlignOpen] = useState<null | Align>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("top-start")}
>
top-start
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("top-center")}
>
top-center
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("top-end")}
>
top-end
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("middle-start")}
>
middle-start
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("middle-center")}
>
middle-center
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("middle-end")}
>
middle-end
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("bottom-start")}
>
bottom-start
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("bottom-center")}
>
bottom-center
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setAlignOpen("bottom-end")}
>
bottom-end
</Button>
</div>
<Modal
show={!!alignOpen}
align={alignOpen ?? "middle-center"}
onShowChange={(show) => !show && setAlignOpen(null)}
>
<Card title={`align="${alignOpen}"`}>
Panel position on all breakpoints. Use useBreakpoint for a different
align per viewport.
</Card>
</Modal>
</div>
);
};
export default ModalAlign;
Responsive align
Combine Modal with useBreakpoint when you want a different align per viewport—for example a bottom sheet on mobile and a centered dialog on larger screens.
<script setup lang="ts">
import { ref } from "vue";
import { useBreakpoint } from "@bridge-ui/vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
const open = ref(false);
const breakpoint = useBreakpoint();
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Open responsive modal</Button>
<Modal
v-model="open"
:align="breakpoint.mobile ? 'bottom-center' : 'middle-center'"
>
<Card title="Responsive align">
Bottom sheet on mobile, centered from the mobile breakpoint up. Use
useBreakpoint to restore the previous mobile bottom-sheet pattern.
</Card>
</Modal>
</div>
</template>
import { useState } from "react";
import { useBreakpoint } from "@bridge-ui/react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
export default function ModalResponsiveAlign() {
const [open, setOpen] = useState(false);
const breakpoint = useBreakpoint();
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Open responsive modal</Button>
<Modal
show={open}
onShowChange={setOpen}
align={breakpoint.mobile ? "bottom-center" : "middle-center"}
>
<Card title="Responsive align">
Bottom sheet on mobile, centered from the mobile breakpoint up. Use
useBreakpoint to restore the previous mobile bottom-sheet pattern.
</Card>
</Modal>
</div>
);
}
Transitions and blur
Set transition to control enter/leave animation (fade, scale, slide-up, slide-down, or none). Combine with blur on the backdrop for depth.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
type Transition = "fade" | "none" | "scale" | "slide-up" | "slide-down";
const transitionOpen = ref<null | Transition>(null);
const modalOpen = ref(false);
const openTransition = (transition: Transition) => {
transitionOpen.value = transition;
modalOpen.value = true;
};
const closeModal = (open: boolean) => {
modalOpen.value = open;
if (!open) transitionOpen.value = null;
};
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" v-on:click="openTransition('none')">
none
</Button>
<Button size="sm" variant="outline" v-on:click="openTransition('fade')">
fade
</Button>
<Button size="sm" variant="outline" v-on:click="openTransition('scale')">
scale
</Button>
<Button
size="sm"
variant="outline"
v-on:click="openTransition('slide-up')"
>
slide-up
</Button>
<Button
size="sm"
variant="outline"
v-on:click="openTransition('slide-down')"
>
slide-down
</Button>
</div>
<Modal
blur="md"
v-if="transitionOpen"
:model-value="modalOpen"
:transition="transitionOpen"
v-on:show-change="closeModal"
>
<Card :title="`transition="${transitionOpen}"`">
Enter/leave animation for overlay and panel. Combined with
blur="md".
</Card>
</Modal>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
type Transition = "fade" | "none" | "scale" | "slide-up" | "slide-down";
const ModalTransitions = () => {
const [transitionOpen, setTransitionOpen] = useState<null | Transition>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setTransitionOpen("none")}
>
none
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setTransitionOpen("fade")}
>
fade
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setTransitionOpen("scale")}
>
scale
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setTransitionOpen("slide-up")}
>
slide-up
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setTransitionOpen("slide-down")}
>
slide-down
</Button>
</div>
<Modal
blur="md"
show={!!transitionOpen}
transition={transitionOpen ?? "fade"}
onShowChange={(show) => !show && setTransitionOpen(null)}
>
<Card title={`transition="${transitionOpen}"`}>
Enter/leave animation for overlay and panel. Combined with
blur="md".
</Card>
</Modal>
</div>
);
};
export default ModalTransitions;
Nested modals
Place a second Modal inside the first. Each layer gets its own overlay and a higher z-index. Escape closes only the topmost modal.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
const outerOpen = ref(false);
const innerOpen = ref(false);
const closeOuter = (open: boolean) => {
outerOpen.value = open;
if (!open) innerOpen.value = false;
};
</script>
<template>
<div class="flex flex-col gap-4">
<Button v-on:click="outerOpen = true">Open nested modals</Button>
<Modal size="lg" :show="outerOpen" v-on:show-change="closeOuter">
<Card title="Outer modal">
<div class="flex flex-col gap-4">
<p>Open a second modal on top while the first stays mounted.</p>
<Button size="sm" v-on:click="innerOpen = true">Open inner</Button>
</div>
</Card>
<Modal
size="sm"
:show="innerOpen"
transition="scale"
v-on:show-change="(s) => (innerOpen = s)"
>
<Card title="Inner modal">Nested layer with scale transition.</Card>
</Modal>
</Modal>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
const ModalNested = () => {
const [outerOpen, setOuterOpen] = useState(false);
const [innerOpen, setInnerOpen] = useState(false);
const closeOuter = (open: boolean) => {
setOuterOpen(open);
if (!open) setInnerOpen(false);
};
return (
<div className="flex flex-col gap-4">
<Button onClick={() => setOuterOpen(true)}>Open nested modals</Button>
<Modal size="lg" show={outerOpen} onShowChange={closeOuter}>
<Card title="Outer modal">
<div className="flex flex-col gap-4">
<p>Open a second modal on top while the first stays mounted.</p>
<Button size="sm" onClick={() => setInnerOpen(true)}>
Open inner
</Button>
</div>
</Card>
<Modal
size="sm"
show={innerOpen}
transition="scale"
onShowChange={setInnerOpen}
>
<Card title="Inner modal">Nested layer with scale transition.</Card>
</Modal>
</Modal>
</div>
);
};
export default ModalNested;
Scroll lock
While open, Modal locks body scroll and adds padding-right on document.body so page content does not shift when the scrollbar disappears. Fixed or sticky UI outside the document flow is not covered by that padding. Bridge UI sets --bridge-scrollbar-compensation on :root with the scrollbar width for the duration of the lock (and clears it when the last locking layer closes):
.my-fixed-header {
padding-inline-end: var(--bridge-scrollbar-compensation, 0px);
}Set disableScrollLock if the page should keep scrolling behind the overlay.
<script setup lang="ts">
import { ref } from "vue";
import { Button } from "@bridge-ui/vue/Components/Button";
import { Card } from "@bridge-ui/vue/Components/Card";
import { Modal } from "@bridge-ui/vue/Components/Modal";
const open = ref(false);
</script>
<template>
<div class="w-full">
<Button v-on:click="open = true">Open without scroll lock</Button>
<Modal v-model="open" disable-scroll-lock>
<Card title="disableScrollLock">
The page behind this overlay can still scroll while the modal is open.
</Card>
</Modal>
</div>
</template>
import { useState } from "react";
import { Button } from "@bridge-ui/react/Components/Button";
import { Card } from "@bridge-ui/react/Components/Card";
import { Modal } from "@bridge-ui/react/Components/Modal";
export default function ModalDisableScrollLock() {
const [open, setOpen] = useState(false);
return (
<div className="w-full">
<Button onClick={() => setOpen(true)}>Open without scroll lock</Button>
<Modal show={open} disableScrollLock onShowChange={setOpen}>
<Card title="disableScrollLock">
The page behind this overlay can still scroll while the modal is open.
</Card>
</Modal>
</div>
);
}
Related components
Card, Button, Drawer, useBreakpoint
Accessibility
- Content under an active modal is inert—users cannot interact with the page behind the overlay.
- Focus is trapped inside the modal by default (disableEnforceFocus disables this).
- Focus returns to the trigger on close unless disableRestoreFocus is set.
- Escape closes the modal when closeOnEscape is true and persistent is false.
- When using Card inside Modal, ensure the card title is referenced for context. Pass aria-labelledby and aria-describedby on Modal via root attributes or customProps pointing to title and description element IDs.
- Enable autoFocus to move focus to the first focusable element when the modal opens.
Anatomy
<div> <!-- Modal root (portaled) -->
<div> <!-- overlay / backdrop -->
<div> <!-- paper panel -->
<!-- children: Card, form, or custom content -->
</div>
</div>Target overlay and panel styling with the classes prop. align applies on all breakpoints; size still sets max width from the sm breakpoint up.
API
| Prop | Type | Default | Description |
|---|---|---|---|
| v-model | boolean | — | Two-way binding for overlay visibility. |
| @close | — | — | Fired when the user dismisses (Escape or click-away). Not fired when the parent sets v-model={false} directly. |
| @show-change | (show: boolean) => void | — | Equivalent to onShowChange when you prefer events over a callback prop. |
| Prop | Type | Default | Description |
|---|---|---|---|
| show | boolean | — | Whether the overlay is visible. Use with onShowChange for controlled state. |
| onShowChange | (show: boolean) => void | — | Called when visibility changes. |
| onClose | () => void | — | Fired when the user dismisses (Escape or click-away). Not fired when the parent sets show={false} directly. |
| Prop | Type | Default | Description |
|---|---|---|---|
| size | ModalSize | "md" | Max width from the sm breakpoint up. |
| align | ModalAlign | "middle-center" | Panel position on all breakpoints. Use useBreakpoint for per-viewport align. |
| transition | ModalTransition | "fade" | Enter/leave animation for overlay and panel. |
| blur | ModalBlur | "none" | Backdrop blur on the overlay. |
| persistent | boolean | false | When true, Escape and overlay clicks do not close the modal. |
| closeOnEscape | boolean | true | Whether the modal closes on Escape. |
| closeOnOverlay | boolean | true | Whether the modal closes on overlay click. |
| hideBackdrop | boolean | false | When true, the backdrop overlay is not rendered. |
| scroll | ModalScroll | "body" | Where scroll happens: page (body) or panel (paper). |
| autoFocus | boolean | false | Focus the first focusable element on open. |
| disableEnforceFocus | boolean | false | When true, focus is not trapped inside the modal. |
| disableRestoreFocus | boolean | false | When true, focus is not restored on close. |
| disableScrollLock | boolean | false | When true, body scroll is not locked while open. |
| keepMounted | boolean | false | When true, the modal stays mounted after closing (hidden). |
| teleportTo | string | false | "body" | Portal target. Pass false to render in place. |
| classes | ModalClasses | — | Class overrides for modal parts. |
| customProps | ModalCustomProps | — | Props forwarded to each modal part. |