Overlay
Modal
Accessible modal dialog with a focus trap and portal
Preview
size
animation
title
fullScreen
persistent
footer
Code
import css from "./Modal.module.css";
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@phosphor-icons/react/dist/csr/X";
type Size = "sm" | "md" | "lg";
type Animation = "zoom" | "scale";
interface ModalProps {
open: boolean;
size: Size;
animation: Animation;
fullScreen?: boolean;
title?: string;
"aria-label"?: string;
closeAriaLabel: string;
persistent?: boolean;
footer?: React.ReactNode;
children: React.ReactNode;
onClose: () => void;
}
type Phase = "enter" | "shown" | "leave";
// MUST match the CSS `transition` (0.3s) — an invisible coupling.
const DURATION = 300;
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function getFocusables(container: HTMLElement): HTMLElement[] {
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.getClientRects().length > 0);
}
function trapFocus(ev: KeyboardEvent, container: HTMLElement | null) {
if (!container) return;
const focusables = getFocusables(container);
if (focusables.length === 0) {
ev.preventDefault();
container.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (!container.contains(active)) {
ev.preventDefault();
first.focus();
return;
}
if (ev.shiftKey && active === first) {
ev.preventDefault();
last.focus();
} else if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
}
}
export default function Modal({
open,
size,
animation,
fullScreen,
title,
"aria-label": ariaLabel,
closeAriaLabel,
persistent,
footer,
children,
onClose,
}: ModalProps) {
// `mounted` lags behind `open` so the modal survives its exit animation.
const [mounted, setMounted] = useState(open);
const [phase, setPhase] = useState<Phase>("enter");
const card = useRef<HTMLDivElement>(null);
const overlay = useRef<HTMLDivElement>(null);
const restoreFocus = useRef<HTMLElement | null>(null);
const pointerDownOnOverlay = useRef(false);
const titleId = useId();
// Pointer guard: a selection dragged from inside and released here must not close.
const onPointerDown = (ev: React.PointerEvent) => {
pointerDownOnOverlay.current = ev.target === ev.currentTarget;
};
const onOverlayClick = (ev: React.MouseEvent) => {
if (
!persistent &&
pointerDownOnOverlay.current &&
ev.target === ev.currentTarget
) {
onClose();
}
};
useEffect(() => {
if (open) setMounted(true);
}, [open]);
// The rAF gives the browser a "from" state to animate out of.
useEffect(() => {
if (!mounted) return;
if (open) {
setPhase("enter");
const raf = requestAnimationFrame(() => setPhase("shown"));
return () => cancelAnimationFrame(raf);
}
setPhase("leave");
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const timer = setTimeout(() => setMounted(false), reduce ? 0 : DURATION);
return () => clearTimeout(timer);
}, [open, mounted]);
useEffect(() => {
if (!mounted) return;
restoreFocus.current = document.activeElement as HTMLElement;
// Fixed elements escape the body padding; the width also ships as --scrollbar-lock so they pad
// themselves.
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
const prevOverflow = document.body.style.overflow;
const prevPad = document.body.style.paddingRight;
document.body.style.overflow = "hidden";
if (scrollbar > 0) {
document.body.style.paddingRight = `${scrollbar}px`;
document.documentElement.style.setProperty(
"--scrollbar-lock",
`${scrollbar}px`,
);
}
const background = Array.from(document.body.children).filter(
(el) => el !== overlay.current,
);
background.forEach((el) => el.setAttribute("inert", ""));
const dialog = card.current;
if (dialog) (getFocusables(dialog)[0] ?? dialog).focus();
return () => {
document.body.style.overflow = prevOverflow;
document.body.style.paddingRight = prevPad;
document.documentElement.style.removeProperty("--scrollbar-lock");
background.forEach((el) => el.removeAttribute("inert"));
restoreFocus.current?.focus?.();
};
}, [mounted]);
// Capture phase, so the key is intercepted before the children see it.
useEffect(() => {
if (!mounted) return;
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape" && !persistent) {
ev.stopPropagation();
onClose();
return;
}
if (ev.key === "Tab") trapFocus(ev, card.current);
};
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [mounted, persistent, onClose]);
if (!mounted) return null;
return createPortal(
<div
ref={overlay}
className={`
${css.overlay}
${css[phase]}
${fullScreen ? css.fullscreen : ""}
`}
onPointerDown={onPointerDown}
onClick={onOverlayClick}
>
<div
className={`
${css.overlay_content}
${css[animation]}
${css[phase]}
${fullScreen ? css.fullscreen : css[size]}
`}
>
<div
ref={card}
className={css.content_card}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-label={!title ? ariaLabel : undefined}
tabIndex={-1}
>
{title && (
<header className={css.card_head}>
<h2 id={titleId} className={css.head_title}>
{title}
</h2>
{!persistent && (
<button
type="button"
className={css.head_close}
onClick={onClose}
aria-label={closeAriaLabel}
>
<XIcon weight="bold" />
</button>
)}
</header>
)}
{!title && !persistent && (
<div className={css.card_close}>
<button
type="button"
className={css.close_button}
onClick={onClose}
aria-label={closeAriaLabel}
>
<XIcon weight="bold" />
</button>
</div>
)}
<div className={css.card_body}>{children}</div>
{footer && <footer className={css.card_foot}>{footer}</footer>}
</div>
</div>
</div>,
document.body,
);
}
.overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-12);
background: color-mix(in srgb, #000 55%, transparent);
transition: opacity 0.3s ease;
&.enter,
&.leave {
opacity: 0;
}
&.shown {
opacity: 1;
}
&.fullscreen {
padding: 0;
}
@media (min-width: 480px) {
padding: var(--space-20);
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
.overlay_content {
width: 100%;
transition: transform 0.3s ease;
&.sm {
max-width: 400px;
}
&.md {
max-width: 640px;
}
&.lg {
max-width: 960px;
}
&.zoom.enter {
transform: scale(0.9);
}
&.zoom.shown {
transform: scale(1);
}
&.zoom.leave {
transform: scale(1.05);
}
&.scale.enter,
&.scale.leave {
transform: scale(0.96) translateY(8px);
}
&.scale.shown {
transform: scale(1) translateY(0);
}
&.fullscreen {
max-width: none;
width: 100vw;
height: 100vh;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
.content_card {
/* Stacking root, so a consumer's backdrop at z-index -1 stays under the content, over the
background. */
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
width: 100%;
max-height: calc(100vh - var(--space-40));
overflow: hidden;
background: var(--color-surface-opaque);
border-radius: var(--radius-12);
box-shadow: 0 20px 60px -10px color-mix(in srgb, #000 45%, transparent);
/* The card receives programmatic focus on open — no ring on itself. */
&:focus {
outline: none;
}
.card_head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-16);
flex-shrink: 0;
padding: var(--space-20) var(--space-24);
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
.head_title {
margin: 0;
font-family: var(--font-heading);
font-size: var(--text-18);
font-weight: var(--weight-600);
color: var(--color-text);
}
.head_close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-32);
height: var(--space-32);
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-full);
color: var(--color-text-secondary);
transition:
background 0.2s ease,
color 0.2s ease;
-webkit-tap-highlight-color: transparent;
svg {
width: 1.125em;
height: 1.125em;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
&:active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
}
.card_close {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
padding: var(--space-20) var(--space-24) var(--space-12);
.close_button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-32);
height: var(--space-32);
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-full);
color: var(--color-text-secondary);
transition:
background 0.2s ease,
color 0.2s ease;
-webkit-tap-highlight-color: transparent;
svg {
width: 1.125em;
height: 1.125em;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
&:active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
}
.card_body {
flex: 1 1 auto;
overflow: auto;
padding: var(--space-24);
color: var(--color-text);
.card_close + & {
padding-top: var(--space-12);
}
}
.card_foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-8);
flex-shrink: 0;
padding: var(--space-20) var(--space-24);
border-top: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
}
}
&.fullscreen .content_card {
height: 100%;
max-height: none;
border-radius: 0;
}
}
}
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import Modal from "./Modal";
type ModalContent<T> =
| React.ReactNode
| ((close: (result?: T) => void) => React.ReactNode);
interface ModalOptions<T = void> {
size: "sm" | "md" | "lg";
animation: "zoom" | "scale";
closeAriaLabel: string;
fullScreen?: boolean;
title?: string;
"aria-label"?: string;
persistent?: boolean;
content: ModalContent<T>;
footer?: ModalContent<T>;
}
interface ModalApi {
open: <T = void>(options: ModalOptions<T>) => Promise<T | undefined>;
}
// Type-erased — the caller's T belongs to the public signature, not here.
interface ModalEntry {
options: ModalOptions<unknown>;
resolve: (result?: unknown) => void;
}
interface ModalProviderProps {
children: React.ReactNode;
}
const ModalContext = createContext<ModalApi | null>(null);
function resolveContent(
content: ModalContent<unknown> | undefined,
close: (result?: unknown) => void,
): React.ReactNode {
return typeof content === "function" ? content(close) : content;
}
export default function ModalProvider({ children }: ModalProviderProps) {
const [entry, setEntry] = useState<ModalEntry | null>(null);
const [open, setOpen] = useState(false);
// State drives the render; this ref drives the resolution from stable callbacks.
const activeEntry = useRef<ModalEntry | null>(null);
const openModal = useCallback(<T,>(options: ModalOptions<T>) => {
activeEntry.current?.resolve(undefined);
return new Promise<T | undefined>((resolve) => {
const next = { options, resolve } as ModalEntry;
activeEntry.current = next;
setEntry(next);
setOpen(true);
});
}, []);
// The entry outlives the close: the primitive owns the exit timing, not this map.
const settle = useCallback((result?: unknown) => {
activeEntry.current?.resolve(result);
activeEntry.current = null;
setOpen(false);
}, []);
const onDismiss = () => settle(undefined);
const api = useMemo<ModalApi>(() => ({ open: openModal }), [openModal]);
const options = entry?.options;
return (
<ModalContext.Provider value={api}>
{children}
{options && (
<Modal
size={options.size}
animation={options.animation}
fullScreen={options.fullScreen}
title={options.title}
aria-label={options["aria-label"]}
closeAriaLabel={options.closeAriaLabel}
persistent={options.persistent}
footer={resolveContent(options.footer, settle)}
open={open}
onClose={onDismiss}
>
{resolveContent(options.content, settle)}
</Modal>
)}
</ModalContext.Provider>
);
}
export function useModal(): ModalApi {
const api = useContext(ModalContext);
if (!api) {
throw new Error("useModal must be used inside <ModalProvider>");
}
return api;
}
<script lang="ts">
type Phase = "enter" | "shown" | "leave";
// MUST match the CSS `transition` (0.3s) — an invisible coupling.
const DURATION = 300;
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function getFocusables(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.getClientRects().length > 0,
);
}
function trapFocus(ev: KeyboardEvent, container: HTMLElement | null) {
if (!container) return;
const focusables = getFocusables(container);
if (focusables.length === 0) {
ev.preventDefault();
container.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (!container.contains(active)) {
ev.preventDefault();
first.focus();
return;
}
if (ev.shiftKey && active === first) {
ev.preventDefault();
last.focus();
} else if (!ev.shiftKey && active === last) {
ev.preventDefault();
first.focus();
}
}
</script>
<script setup lang="ts">
import { ref, useId, useTemplateRef, watchEffect } from "vue";
import { PhX } from "@phosphor-icons/vue";
type Size = "sm" | "md" | "lg";
type Animation = "zoom" | "scale";
interface ModalProps {
size: Size;
animation: Animation;
fullScreen?: boolean;
title?: string;
ariaLabel?: string;
closeAriaLabel: string;
persistent?: boolean;
}
const { ariaLabel, persistent } = defineProps<ModalProps>();
const open = defineModel<boolean>("open", { required: true });
// `mounted` lags behind `open` so the modal survives its exit animation.
const mounted = ref(open.value);
const phase = ref<Phase>("enter");
const card = useTemplateRef<HTMLDivElement>("card");
const overlay = useTemplateRef<HTMLDivElement>("overlay");
const restoreFocus = ref<HTMLElement | null>(null);
const pointerDownOnOverlay = ref(false);
const titleId = useId();
const close = () => (open.value = false);
// Pointer guard: a selection dragged from inside and released here must not close.
const onPointerDown = (ev: PointerEvent) => {
pointerDownOnOverlay.value = ev.target === ev.currentTarget;
};
const onOverlayClick = (ev: MouseEvent) => {
if (
!persistent &&
pointerDownOnOverlay.value &&
ev.target === ev.currentTarget
) {
close();
}
};
watchEffect(
() => {
if (open.value) mounted.value = true;
},
{ flush: "post" },
);
// The rAF gives the browser a "from" state to animate out of.
watchEffect(
(onCleanup) => {
if (!mounted.value) return;
if (open.value) {
phase.value = "enter";
const raf = requestAnimationFrame(() => (phase.value = "shown"));
onCleanup(() => cancelAnimationFrame(raf));
return;
}
phase.value = "leave";
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const timer = setTimeout(
() => (mounted.value = false),
reduce ? 0 : DURATION,
);
onCleanup(() => clearTimeout(timer));
},
{ flush: "post" },
);
watchEffect(
(onCleanup) => {
if (!mounted.value) return;
restoreFocus.value = document.activeElement as HTMLElement;
// Fixed elements escape the body padding; the width also ships as --scrollbar-lock so they pad
// themselves.
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
const prevOverflow = document.body.style.overflow;
const prevPad = document.body.style.paddingRight;
document.body.style.overflow = "hidden";
if (scrollbar > 0) {
document.body.style.paddingRight = `${scrollbar}px`;
document.documentElement.style.setProperty(
"--scrollbar-lock",
`${scrollbar}px`,
);
}
const background = Array.from(document.body.children).filter(
(el) => el !== overlay.value,
);
background.forEach((el) => el.setAttribute("inert", ""));
const dialog = card.value;
if (dialog) (getFocusables(dialog)[0] ?? dialog).focus();
onCleanup(() => {
document.body.style.overflow = prevOverflow;
document.body.style.paddingRight = prevPad;
document.documentElement.style.removeProperty("--scrollbar-lock");
background.forEach((el) => el.removeAttribute("inert"));
restoreFocus.value?.focus?.();
});
},
{ flush: "post" },
);
// Capture phase, so the key is intercepted before the children see it.
watchEffect(
(onCleanup) => {
if (!mounted.value) return;
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key === "Escape" && !persistent) {
ev.stopPropagation();
close();
return;
}
if (ev.key === "Tab") trapFocus(ev, card.value);
};
document.addEventListener("keydown", onKeyDown, true);
onCleanup(() => document.removeEventListener("keydown", onKeyDown, true));
},
{ flush: "post" },
);
</script>
<template>
<Teleport to="body">
<div
v-if="mounted"
ref="overlay"
class="overlay"
:class="[phase, { fullscreen: fullScreen }]"
@pointerdown="onPointerDown"
@click="onOverlayClick"
>
<div
class="overlay_content"
:class="[animation, phase, fullScreen ? 'fullscreen' : size]"
>
<div
ref="card"
class="content_card"
role="dialog"
aria-modal="true"
:aria-labelledby="title ? titleId : undefined"
:aria-label="!title ? ariaLabel : undefined"
tabindex="-1"
>
<header v-if="title" class="card_head">
<h2 :id="titleId" class="head_title">{{ title }}</h2>
<button
v-if="!persistent"
type="button"
class="head_close"
:aria-label="closeAriaLabel"
@click="close"
>
<PhX weight="bold" />
</button>
</header>
<div v-if="!title && !persistent" class="card_close">
<button
type="button"
class="close_button"
:aria-label="closeAriaLabel"
@click="close"
>
<PhX weight="bold" />
</button>
</div>
<div class="card_body"><slot></slot></div>
<footer v-if="$slots.footer" class="card_foot">
<slot name="footer"></slot>
</footer>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-12);
background: color-mix(in srgb, #000 55%, transparent);
transition: opacity 0.3s ease;
&.enter,
&.leave {
opacity: 0;
}
&.shown {
opacity: 1;
}
&.fullscreen {
padding: 0;
}
@media (min-width: 480px) {
padding: var(--space-20);
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
.overlay_content {
width: 100%;
transition: transform 0.3s ease;
&.sm {
max-width: 400px;
}
&.md {
max-width: 640px;
}
&.lg {
max-width: 960px;
}
&.zoom.enter {
transform: scale(0.9);
}
&.zoom.shown {
transform: scale(1);
}
&.zoom.leave {
transform: scale(1.05);
}
&.scale.enter,
&.scale.leave {
transform: scale(0.96) translateY(8px);
}
&.scale.shown {
transform: scale(1) translateY(0);
}
&.fullscreen {
max-width: none;
width: 100vw;
height: 100vh;
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
.content_card {
/* Stacking root, so a consumer's backdrop at z-index -1 stays under the content, over the
background. */
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
width: 100%;
max-height: calc(100vh - var(--space-40));
overflow: hidden;
background: var(--color-surface-opaque);
border-radius: var(--radius-12);
box-shadow: 0 20px 60px -10px color-mix(in srgb, #000 45%, transparent);
/* The card receives programmatic focus on open — no ring on itself. */
&:focus {
outline: none;
}
.card_head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-16);
flex-shrink: 0;
padding: var(--space-20) var(--space-24);
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
.head_title {
margin: 0;
font-family: var(--font-heading);
font-size: var(--text-18);
font-weight: var(--weight-600);
color: var(--color-text);
}
.head_close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-32);
height: var(--space-32);
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-full);
color: var(--color-text-secondary);
transition:
background 0.2s ease,
color 0.2s ease;
-webkit-tap-highlight-color: transparent;
svg {
width: 1.125em;
height: 1.125em;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
&:active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
}
.card_close {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
padding: var(--space-20) var(--space-24) var(--space-12);
.close_button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-32);
height: var(--space-32);
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-full);
color: var(--color-text-secondary);
transition:
background 0.2s ease,
color 0.2s ease;
-webkit-tap-highlight-color: transparent;
svg {
width: 1.125em;
height: 1.125em;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
&:active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
}
.card_body {
flex: 1 1 auto;
overflow: auto;
padding: var(--space-24);
color: var(--color-text);
.card_close + & {
padding-top: var(--space-12);
}
}
.card_foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-8);
flex-shrink: 0;
padding: var(--space-20) var(--space-24);
border-top: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
}
}
&.fullscreen .content_card {
height: 100%;
max-height: none;
border-radius: 0;
}
}
}
</style>
<script lang="ts">
import { inject, type InjectionKey, type VNode } from "vue";
type ModalNodes = VNode | VNode[];
type ModalContent<T> =
| ModalNodes
| ((close: (result?: T) => void) => ModalNodes);
interface ModalOptions<T = void> {
size: "sm" | "md" | "lg";
animation: "zoom" | "scale";
closeAriaLabel: string;
fullScreen?: boolean;
title?: string;
"aria-label"?: string;
persistent?: boolean;
content: ModalContent<T>;
footer?: ModalContent<T>;
}
interface ModalApi {
open: <T = void>(options: ModalOptions<T>) => Promise<T | undefined>;
}
const modalKey: InjectionKey<ModalApi> = Symbol("modal");
export function useModal(): ModalApi {
const api = inject(modalKey, null);
if (!api) {
throw new Error("useModal must be used inside <ModalProvider>");
}
return api;
}
</script>
<script setup lang="ts">
import { provide, ref, shallowRef } from "vue";
import Modal from "./Modal.vue";
// Type-erased — the caller's T belongs to the public signature, not here.
interface ModalEntry {
options: ModalOptions<unknown>;
resolve: (result?: unknown) => void;
}
// Shallow: the entry carries vnodes and callbacks; only the swap has to be reactive.
const entry = shallowRef<ModalEntry | null>(null);
const open = ref(false);
const openModal = <T,>(options: ModalOptions<T>) => {
entry.value?.resolve(undefined);
return new Promise<T | undefined>((resolve) => {
entry.value = { options, resolve } as ModalEntry;
open.value = true;
});
};
// The entry outlives the close: the primitive owns the exit timing, not this map.
const settle = (result?: unknown) => {
entry.value?.resolve(result);
open.value = false;
};
// The modal only ever writes `false` back — v-model where React has onClose.
const onDismiss = () => settle(undefined);
provide(modalKey, { open: openModal });
const resolveContent = (content: ModalContent<unknown> | undefined) =>
typeof content === "function" ? content(settle) : content;
const Content = () => resolveContent(entry.value?.options.content) ?? null;
const Footer = () => resolveContent(entry.value?.options.footer) ?? null;
</script>
<template>
<slot></slot>
<Modal
v-if="entry"
:open="open"
:size="entry.options.size"
:animation="entry.options.animation"
:fullScreen="entry.options.fullScreen"
:title="entry.options.title"
:aria-label="entry.options['aria-label']"
:closeAriaLabel="entry.options.closeAriaLabel"
:persistent="entry.options.persistent"
@update:open="onDismiss"
>
<Content />
<template v-if="entry.options.footer" #footer>
<Footer />
</template>
</Modal>
</template>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| open | boolean | required | Visibility (controlled); the parent owns the state. |
| onClose | () => void | required | Callback when the modal asks to close (Esc, outside click, X). |
| size | "sm" | "md" | "lg" | required | Maximum width of the card. |
| animation | "zoom" | "scale" | required | Enter and exit animation. |
| fullScreen | boolean | — | Fills the whole screen. |
| title | string | — | Dialog title (aria-labelledby); without it, the name comes from aria-label. |
| aria-label | string | — | Dialog name when there is no title; without either, a screen reader only announces "dialog". |
| closeAriaLabel | string | required | Name of the close button for screen readers. |
| persistent | boolean | — | Does not close on Esc or outside click and hides the X; the content must offer the way out. |
| footer | React.ReactNode | — | Actions area at the bottom. |
| children | React.ReactNode | required | Body content. |
Imperative API
Optional sugar over the declarative <Modal>. Wrap your app in ModalProvider and open it with a call.
result: —
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | required | Maximum width of the card. |
| animation | "zoom" | "scale" | required | Enter and exit animation. |
| closeAriaLabel | string | required | Name of the close button for screen readers. |
| title | string | — | Dialog title (aria-labelledby); without it, the name comes from aria-label. |
| aria-label | string | — | Dialog name when there is no title; without either, a screen reader only announces "dialog". |
| fullScreen | boolean | — | Fills the whole screen. |
| persistent | boolean | — | Does not close on Esc or outside click and hides the X; the content must offer the way out. |
| content | ReactNode | (close: (result?: T) => void) => ReactNode | required | Body content. As a function it receives close(result) to dismiss resolving a value. |
| footer | ReactNode | (close: (result?: T) => void) => ReactNode | — | Actions area at the bottom. Same function contract as content. |
| → open() | Promise<T | undefined> | — | Resolves with the value from close(value); Esc, backdrop or X resolve undefined. A second open() replaces the active one (resolves undefined). |