Skip to content

Modal

Accessible modal dialog built on Reka UI's Dialog primitives. Includes a useModal() composable with view-stack support — push/pop views inside one modal instance instead of stacking dialogs.

bash
npm install @vuecs/overlays

Compound API

<VCModal> is a compound component. Each part is a thin wrapper over the matching Reka Dialog primitive, themed via useComponentTheme('modal', ...):

ComponentWrapsPurpose
VCModalDialogRootHolds open state. v-models open.
VCModalTriggerDialogTriggerButton that toggles open.
VCModalContentDialogPortal + DialogOverlay + DialogContentBackdrop + focused panel. Use inline to skip the portal, hideOverlay to skip the backdrop.
VCModalTitleDialogTitlearia-labelledby target.
VCModalDescriptionDialogDescriptionaria-describedby target.
VCModalCloseDialogCloseButton that closes. Slotless <VCModalClose /> renders the corner-X (default × glyph + closeIcon theme slot). With slot content (e.g. "Cancel") it renders neutrally so consumer classes compose. Pass icon to force the corner-X even with custom content. Auto aria-label="Close" when slotless.
vue
<script setup lang="ts">
import {
    VCModal,
    VCModalClose,
    VCModalContent,
    VCModalDescription,
    VCModalTitle,
    VCModalTrigger,
} from '@vuecs/overlays';
import { ref } from 'vue';

const open = ref(false);
</script>

<template>
    <VCModal v-model:open="open">
        <VCModalTrigger>Open dialog</VCModalTrigger>
        <VCModalContent>
            <!-- Slotless = corner-X (theme `closeIcon` slot, absolute
                 right-3 top-3). -->
            <VCModalClose />
            <VCModalTitle>Confirm action</VCModalTitle>
            <VCModalDescription>
                This will permanently delete the record.
            </VCModalDescription>
            <div class="flex justify-end gap-2">
                <!-- With slot content, <VCModalClose> uses the neutral
                     `close` theme slot so consumer classes compose. -->
                <VCModalClose
                    class="rounded-md border border-border bg-bg px-3 py-1.5 text-sm font-medium hover:bg-bg-muted"
                >
                    Cancel
                </VCModalClose>
            </div>
        </VCModalContent>
    </VCModal>
</template>
css
@import "tailwindcss";
@import "@vuecs/design";

@custom-variant dark (&:where(.dark, .dark *));

Two presentations, one component

<VCModalClose> picks between two theme slots based on slot content and the icon prop:

  • <VCModalClose /> (no slot, no icon) — pre-styled top-right corner ×. Reads the theme's closeIcon slot (absolute right-3 top-3 h-7 w-7 in theme-tailwind, btn-close in theme-bootstrap). Drop one inside <VCModalContent> for the standard dismiss affordance.
  • <VCModalClose>Cancel</VCModalClose> (with slot content) — neutral close trigger that reads the close slot. Consumer classes via class= or :theme-class compose cleanly. Right choice for Cancel/Confirm rows or any labelled close button.
  • <VCModalClose icon>...</VCModalClose> — explicit icon forces the corner-X presentation even when you supply custom slot content (e.g. a custom icon).

useModal() composable

For flows like "list view → detail view → back" that would otherwise stack modals or fight z-index, useModal() exposes a view-stack and Escape/backdrop handling that pops the stack first, then closes when the stack is empty.

ts
import { useModal, type ModalView } from '@vuecs/overlays';
import ListView from './ListView.vue';
import DetailView from './DetailView.vue';

const modal = useModal({
    onClose: () => {
        // refetch / reset / cleanup after the modal fully closes
    },
});

modal.open({ component: ListView });
//        ↳ depth = 1, currentView = ListView

modal.pushView({ component: DetailView, props: { id: 42 } });
//        ↳ depth = 2, currentView = DetailView

modal.popView();
//        ↳ depth = 1, currentView = ListView again

modal.popView();
//        ↳ depth = 0, modal closes, onClose() fires

API

ts
type UseModalOptions = {
    initialView?: ModalView;
    onClose?: () => void;
};

type ModalView = {
    key?: string | number | symbol;
    component: Component;
    props?: Record<string, unknown>;
    title?: string;
};

type UseModalReturn = {
    isOpen: Ref<boolean>;
    currentView: ComputedRef<ModalView | undefined>;
    hasHistory: ComputedRef<boolean>;
    depth: ComputedRef<number>;

    open: (view?: ModalView) => void;
    close: () => void;
    pushView: (view: ModalView) => void;
    popView: () => void;
    replaceView: (view: ModalView) => void;
    setOpen: (next: boolean) => void;
};

Wiring with the compound API

vue
<script setup lang="ts">
import { VCModal, VCModalContent, VCModalTitle, useModal } from '@vuecs/overlays';
import ListView from './ListView.vue';
import DetailView from './DetailView.vue';

const modal = useModal();

const showItem = (id: number) => {
    modal.pushView({ component: DetailView, props: { id }, title: `Item #${id}` });
};
</script>

<template>
    <button @click="modal.open({ component: ListView, title: 'List' })">
        Open list
    </button>

    <VCModal :open="modal.isOpen.value" @update:open="modal.setOpen">
        <VCModalContent>
            <header class="flex items-center gap-2">
                <button v-if="modal.hasHistory.value" @click="modal.popView()">←</button>
                <VCModalTitle>{{ modal.currentView.value?.title }}</VCModalTitle>
            </header>
            <component
                :is="modal.currentView.value.component"
                v-if="modal.currentView.value"
                v-bind="modal.currentView.value.props"
                @select="showItem"
            />
        </VCModalContent>
    </VCModal>
</template>
css
@import "tailwindcss";
@import "@vuecs/design";

@custom-variant dark (&:where(.dark, .dark *));

Theme keys

KeyDefault classNotes
overlayvc-modal-overlayBackdrop layer behind the dialog.
contentvc-modal-contentFocused panel.
headervc-modal-headerHeader layout container (consumer-composed).
titlevc-modal-titleDialogTitle element.
descriptionvc-modal-descriptionDialogDescription element.
bodyvc-modal-bodyBody layout container (consumer-composed).
footervc-modal-footerFooter layout container (consumer-composed).
triggervc-modal-triggerTrigger button.
closevc-modal-closeGeneric close trigger (<VCModalClose>). Neutral baseline so consumer classes compose cleanly.
closeIconvc-modal-close-iconCorner-X positioning + sizing for <VCModalCloseIcon>.
backvc-modal-backOptional view-stack back button.

@vuecs/theme-tailwind ships pre-built styling for every key with light/dark mode and data-state="open|closed" animation hooks.

Accessibility

The Reka Dialog primitives provide:

  • Focus trap — focus stays inside <VCModalContent> while open and restores to the trigger on close.
  • Scroll lock — body scroll is disabled while a modal is open (modal: true mode).
  • Escape key — closes the modal. Combine with useModal()'s popView() for view-stack flows by intercepting update:open to call popView while hasHistory is true.
  • ARIArole="dialog", aria-modal, aria-labelledby (linked to <VCModalTitle>), aria-describedby (linked to <VCModalDescription>).

Animations

Both theme-tailwind and theme-bootstrap ship enter and exit animations out of the box (fade + zoom-95 on <VCModalContent>, fade-only on the overlay). Animation classes resolve through @vuecs/design's animations.css — a vanilla-CSS port of tw-animate-css, so the same class names work for any theme.

How the per-state gating works in each theme:

  • theme-tailwind uses Tailwind's data-[state=open]:animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out fade-out-0 zoom-out-95 composition — Tailwind compiles each variant to a selector scoped to the matching data-state.
  • theme-bootstrap uses vuecs's dual-state helper classes (vc-overlay-anim, vc-overlay-fade-anim) which package the same gating into a single class. Required because BS5 theme strings can't carry data-[state=]: attribute selectors.

Reka's DialogContent already wraps with Presence internally — it reads the element's computed animation-name when data-state flips to closed, suspends unmount, and waits for animationend before removing the element. So exit animations actually play; nothing extra to wire on the vuecs side.

Per-instance overrides (e.g. opt out of motion entirely):

vue
<VCModalContent :theme-class="{ content: '', overlay: '' }">
    ...
</VCModalContent>

The prefers-reduced-motion: reduce CSS media query also disables every animation in animations.css automatically.

API Reference

<VCModal>

Holds the open/closed state and provides context to every nested part. Wraps Reka's DialogRoot.

PropTypeDefaultDescription
openboolean | undefinedundefinedControlled open state. Use v-model:open or pair :open with @update:open.
defaultOpenbooleanfalseInitial open state for uncontrolled usage (when open is omitted).
modalbooleantrueTrap focus inside the dialog and disable interaction with content outside.
EmitPayloadDescription
update:openbooleanFired when the open state changes (Escape, click-outside, <VCModalClose> click).

<VCModalTrigger>

Button that toggles the modal open. Composes DialogTrigger over the configured as element.

PropTypeDefaultDescription
asstring'button'HTML tag to render. Set to 'div', 'span', etc. when you need non-button semantics.
asChildbooleanfalseRender the trigger via the default slot's child element instead of as. The slot's element receives the trigger's listeners + ARIA.
themeClassPartial<ModalThemeClasses>undefinedPer-instance theme override (slot keys → class strings).
themeVariantRecord<string, string | boolean>undefinedPer-instance variant values.

<VCModalContent>

Floating dialog panel. Bundles DialogPortal + DialogOverlay + DialogContent so consumers don't compose them manually.

PropTypeDefaultDescription
inlinebooleanfalseSkip the DialogPortal and render where the component sits in the DOM. Useful for testing or custom mounting.
hideOverlaybooleanfalseSkip the backdrop element.
themeClassPartial<ModalThemeClasses>undefinedPer-instance theme override.
themeVariantRecord<string, string | boolean>undefinedPer-instance variant values.

Extra DialogContent props (onEscapeKeyDown, onPointerDownOutside, onInteractOutside, etc.) pass through via attrs.

<VCModalTitle>

Accessible dialog title, linked to the panel via aria-labelledby. Wraps DialogTitle.

PropTypeDefaultDescription
themeClassPartial<ModalThemeClasses>undefinedPer-instance theme override.
themeVariantRecord<string, string | boolean>undefinedPer-instance variant values.

<VCModalDescription>

Accessible description, linked via aria-describedby. Wraps DialogDescription.

PropTypeDefaultDescription
themeClassPartial<ModalThemeClasses>undefinedPer-instance theme override.
themeVariantRecord<string, string | boolean>undefinedPer-instance variant values.

<VCModalClose>

Button that dismisses the modal. Wraps DialogClose. Picks between the closeIcon slot (pre-styled corner-X) and the close slot (neutral) based on slot content + the icon prop:

  • Slotless (<VCModalClose />) — corner-X via closeIcon. Renders the default × glyph.
  • With slot content (<VCModalClose>Cancel</VCModalClose>) — neutral close slot, so consumer classes via class= or :theme-class compose cleanly.
  • Explicit icon prop — always reads the closeIcon slot, regardless of slot content.

Auto-applies aria-label="Close" when slotless so screen readers don't announce the bare × glyph as "multiplication sign". Pass an explicit aria-label via attrs to override, or supply visible text content (which takes precedence and drops the auto-label).

PropTypeDefaultDescription
asstring'button'HTML tag to render.
asChildbooleanfalseRender via the default slot's child element.
iconbooleanfalseForce the closeIcon (corner-X) slot even when slot content is provided. Not needed for slotless usage — bare <VCModalClose /> already picks closeIcon.
themeClassPartial<ModalThemeClasses>undefinedPer-instance theme override.
themeVariantRecord<string, string | boolean>undefinedPer-instance variant values.

useModal(options?)

Reactive view-stack composable. See useModal() composable above for usage.

OptionTypeDefaultDescription
initialViewModalViewundefinedPushed onto the stack the first time open() is called without a view argument.
onClose() => voidundefinedCalled after the modal closes (after the stack is cleared).

The return shape (isOpen / currentView / hasHistory / depth / open / close / pushView / popView / replaceView / setOpen) is documented in the API block above.

Header / body / footer

header, body, and footer are theme keys, not components — vuecs doesn't ship <VCModalHeader> etc. Compose them as plain <div> / <header> / <footer> and apply the theme classes manually if you want the layout helpers from theme-tailwind. The theme key list above shows the default classes.

Status

@vuecs/overlays ships Modal alongside Popover, Tooltip, DropdownMenu, and ContextMenu — all on the same compound + useComponentTheme shape. See the Reka UI adoption roadmap for the broader plan.

Released under the Apache 2.0 License.