GrModal

  • core
  • overlays
  • API · 7 props
  • ⌨ keys

A modal window for an important scenario that temporarily blocks the background.

import { GrModal } from '@feugene/granularity/components/GrModal'

Overview

When to take it

  • a layout of your own on top of the page — a gallery, a builder, a full-screen wizard: there are deliberately no header and footer here;
  • only the modal layer is needed — the backdrop, the scroll lock, the focus trap, Esc and the layer stack;
  • an overlay component of your own is being built — this is the same primitive GrDialog, GrDrawer and the command palette stand on;
  • the scrolling behaves non-standardly — scrollBehavior moves it from the body to the whole layer.

When to take something else

NeedTake
An ordinary “header — body — footer” windowGrDialog
Ask a yes/no questionGrConfirmDialog
Ask for a single valueGrPromptDialog
A panel at the edge of the screenGrDrawer
Viewing an image full screenGrImageViewer

There is no second implementation of the modal layer in the package and there is no need to start one: the stack, the inert for the background and the return of focus to the trigger arrive from here into all of the overlays at once.

Playground controls come from the API, not from hand

Stage

The window opens above the page with the props set on the right.

0 changed· state is in the URL

Examples 4 each with a live demo and its code

  • A layout of your own

    The panel is a frame and nothing more — no paddings, so a coloured half reaches the edges and the corners. Without a #title slot the window is named by aria-label; everything else — backdrop, focus trap, Esc — is the primitive’s.

    Single sign-onLet your team sign in with Okta, Entra ID or Google Workspace.
  • Edge to edge, named by its slots

    A chart runs to both edges of the panel, while the text brings its own paddings. #title and #description become aria-labelledby and aria-describedby, so the window is announced with its heading and the total.

    Net revenue, Q3€55,963.80
  • A window that has to be answered

    :close-on-backdrop="false" and :close-on-esc="false" leave exactly one way out — the buttons: a stray click must not leave the workspace read-only. scroll-behavior="inside" keeps the title and the answer in view while the text scrolls.

    Terms of ServiceUpdated on 1 November 2026 · needs an owner’s answer
  • The whole screen for a wizard

    size="full" is «the whole screen», not «very wide»: no margins and no corners, so a three-step import gets its own top bar, body and bottom bar. closed puts the wizard back to the first step once the panel has gone.

    No customers yetBring them over from your old tool in one go.

API generated from the package · cannot drift

Props

PropTypedefaultDescription
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefinedThe width of the panel: from `sm` to `xl` only the maximum width changes, `full` is the whole viewport, with no margins and no rounded corners.
ariaLabelstring | undefinedundefinedThe accessible name of the window when there is no heading in the `#title` slot. The slot is stronger: with it the name comes from `aria-labelledby`.
closeOnBackdropboolean | undefinedtrueClose on a click on the backdrop. Only a click that started on it counts: a text selection released outside the panel does not close the window.
closeOnEscboolean | undefinedtrueClose on Esc. The top layer of the stack closes, not the one lower in the document.
scrollBehaviorGrModalScrollBehavior | undefined"outside"What scrolls when the content is long: the whole overlay (`outside`) or the panel itself (`inside` — the window stays in place, the header and the footer in view).
initialFocusHTMLElement | null | undefinednullThe element that gets the focus on opening. By default — the panel itself: it is focusable programmatically (`tabindex="-1"`), a screen reader announces the window as a whole, and the first Tab brings you to the beginning of the content.
modelValuerequiredv-modelboolean—Whether the window is open (`v-model`). The window has no state of its own: the parent opens and closes it.

Slots

SlotTypeDescription
defaultanyThe content of the window. With `scrollBehavior: 'inside'` only it scrolls.
titleanyThe heading of the window. It gives the window its accessible name through `aria-labelledby` — stronger than `ariaLabel`.
descriptionanyAn explanation for the window, linked to it through `aria-describedby`.
headeranyA pinned header: with `inside` it stays in place, and only the body scrolls.
footeranyA pinned footer: in the same place as the header — outside the scrolling body.

Events

EventSignatureDescription
update:modelValue(event: "update:modelValue", value: boolean): void—
opened(event: "opened"): void—
closed(event: "closed"): void—

Methods

MethodsSignatureDescription
open() => voidOpen the window: emits `update:modelValue` with `true` — the parent opens it.
close() => voidClose the window: emits `update:modelValue` with `false`.
toggle() => voidToggle the window: emits `update:modelValue` with the opposite value.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrModalProps, GrModalEmits, GrModalSize } from '@feugene/granularity/components/GrModal'

Filedist/types/components/GrModal/GrModal.vue.d.ts

GrModalPropsinterface
interface GrModalProps {
    modelValue: boolean;
    closeOnBackdrop?: boolean;
    closeOnEsc?: boolean;
    size?: GrModalSize;
    scrollBehavior?: GrModalScrollBehavior;
    ariaLabel?: string;
    initialFocus?: HTMLElement | null;
}
GrModalEmitsinterface
interface GrModalEmits {
    (e: 'update:modelValue', value: boolean): void;
    (e: 'opened'): void;
    (e: 'closed'): void;
}

Filedist/types/components/GrModal/config.d.ts

grModalConfigconst
const grModalConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrModal">;

Filedist/types/components/GrModal/grModalStyles.d.ts

GrModalSizetype
type GrModalSize = GrOverlaySize;
GrModalScrollBehaviortype
type GrModalScrollBehavior = 'outside' | 'inside';

Filedist/types/components/GrModal/safelist.d.ts

grModalSafelistconst
const grModalSafelist: string[];

Filedist/types/components/GrModal/index.d.ts

GrModalInstancetype
type GrModalInstance = ComponentExposed<typeof GrModalComponent>;

Accessibility

Keys

EscTab / Shift+Tab

Usage from the library prose

The name of the window is mandatory

A modal layer without an accessible name is a violation that a screen reader announces as a nameless “dialog” and that axe catches with the aria-dialog-name rule. The order is this:

  1. there is a #title (or a GrDialog heading deeper in the tree) — it gives the name through aria-labelledby;
  2. there is no #title but there is an ariaLabel — the name is taken from the prop;
  3. there is nothing — a generic name from the locale is substituted (gr.modal.title), and in a dev build a warning is printed on the first opening.

The third point is a safety net rather than a mode of operation: a generic “Dialog” is better than emptiness, but a meaningful name is known only to the author of the window.

vue
<GrModal v-model="open" aria-label="Import from CSV">
  <ImportWizard />
</GrModal>
The size

size — from sm to xl — changes only the maximum width of the panel; the margins around the window and the rounding remain.

full is a separate mode: the panel takes up the whole viewport, with no margins of the shell and no rounding. That is “full screen” rather than “very wide”: an import wizard on mobile has to occupy the screen rather than leave a four-pixel frame.

A panel without padding is a decision, not an omission

The panel of a window is a frame and nothing more: a border, a background, a shadow, a radius and clipping by it (panelBase in grModalStyles.ts). The #title, #header and #footer slots and the content are rendered as they are, with no padding at all, so a bare GrModal looks edge to edge.

Padding inside the panel would have to be cancelled every time the content is obliged to reach the edge: an image or a map across the full width, a table with a grid of its own, a toolbar, a strip of steps with a rule across the whole panel. The cancelling is done with negative margins, and they fall out with the radius and with the overflow-hidden of the panel. The other way round — adding padding to something that has none — costs one container and breaks nothing.

The padding, the header and the footer therefore live one storey up, in GrDialog: px-5 horizontally and py-3 / py-5 / py-4 for the header, the body and the footer (dialogShared.ts), each configured through headerConfig / bodyConfig / footerConfig. The division is the same as in GrCard and its sections: the mechanics in one component, the rhythm in another.

Scrolling long content

scrollBehavior:

  • outside (the default) — the whole overlay scrolls, and the window moves up together with the page;
  • inside — the panel is limited by the height of the viewport, and only its body scrolls. The #title, #description, #header and #footer slots stay in place in the process: the panel becomes a column, and the heading does not move away with the content.

The scrolling is always in exactly one place: two scrollbars for one window is a bug, not a safety margin.

A header and a footer of your own can be pinned with the #header and #footer slots: they lie outside the scrolling body and stay in place with inside. The primitive adds no markup of its own to them — they are empty layout areas that GrDialog makes use of. With inside the body enters the tab order: otherwise a long text without a single focusable element cannot be scrolled from the keyboard.

Esc, the layer and the focus

The window registers itself in the shared layer stack (useOverlayLayer, modal: true). The stack swallows Escape in the capture phase on window, so:

  • the top layer closes rather than the one that happened to be lower in the document: a dropdown opened inside a window closes itself on Esc, and the next Esc closes the window;
  • the press does not reach the local handlers at all, so closeOnEsc and closeOnBackdrop do not overlap: the first is about Esc, the second about the click.

The open windows below are marked inert, so that the focus trap of a lower one does not take the focus away from the upper one. The dialogs of useDialogService are mounted with a separate render() into body and still enter the same stack — that is the point of it.

The height comes from there as well: every open window gets a level of its own inside --gr-z-modal — calc(var(--gr-z-modal) + depth). Otherwise “the top one” for rendering and “the top one” for inert diverge: the order of the nodes in the portal is set by the creation of a component, and a statically declared dialog opened later would end up under the window while remaining the only one that answers clicks (see ../z-index.md).

While the window is open, the rest of the content of body also goes into inert and aria-hidden: covering it is not enough, otherwise Tab leads to the page under the window and a screen reader reads it as an ordinary one. The roots of other layers (toasts, the panel of a select opened from inside the window) are not dimmed in the process.

The focus trap is useFocusTrap (a public composable of the package): Tab walks in a ring inside the window, and a leaked focus is returned. Panels opened from inside the window are teleported into body and lie outside its subtree — the trap knows about them from the layer stack and does not take the focus away from them.

initialFocus sets the element that gets the focus on opening. By default that is the panel itself (tabindex="-1"): a screen reader announces the window as a whole, and the first Tab brings you to the beginning of the content. A focus that the content of the window set in the same tick (GrConfirmDialog aims at “Cancel”, GrPromptDialog at the field) is not overridden by the trap.

A click on the backdrop

Only a click that started on the backdrop closes the window. A text selection started in the panel and released beyond its border does not close it — otherwise careful work with text inside a window would turn into losing it.

The imperative API

open(), close() and toggle() are available through a ref on the component — the same set as in GrDropdown, GrDialog, GrCommandPalette and GrPopover.

vue
<GrModal ref="modal" v-model="open" aria-label="Settings">
…
</GrModal>

<script setup>
const modal = ref()
modal.value.open()
</script>

The window is controlled, so the methods ask the parent: they emit update:modelValue, and the state stays in its v-model. Without a model bound, a call will open nothing — the window deliberately has no state of its own, otherwise it would diverge from the source of truth.

The lifecycle

opened and closed are emitted after the animation. closed is the only safe moment to unmount the content: doing that on update:modelValue means cutting the closing animation short.