GrModal
A modal window for an important scenario that temporarily blocks the background.
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,
Escand the layer stack; - an overlay component of your own is being built — this is the same primitive
GrDialog,GrDrawerand the command palette stand on; - the scrolling behaves non-standardly —
scrollBehaviormoves it from the body to the whole layer.
When to take something else
| Need | Take |
|---|---|
| An ordinary “header — body — footer” window | GrDialog |
| Ask a yes/no question | GrConfirmDialog |
| Ask for a single value | GrPromptDialog |
| A panel at the edge of the screen | GrDrawer |
| Viewing an image full screen | GrImageViewer |
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
The window opens above the page with the props set on the right.
0 changed· state is in the URL
<GrModal v-model="value">
<GrDialogHeader title="Rename the report" :showCloseButton="false" />
<GrDialogFooter>GrModal draws no paddings: this header and footer bring their own. Esc or a click outside closes it.</GrDialogFooter>
</GrModal>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
#titleslot the window is named byaria-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.
#titleand#descriptionbecomearia-labelledbyandaria-describedby, so the window is announced with its heading and the total.Net revenue, Q3€55,963.80A 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 answerThe 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.closedputs 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
| Prop | Type | default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "xl" | "full" | undefined | undefined | The 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. |
ariaLabel | string | undefined | undefined | The 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`. |
closeOnBackdrop | boolean | undefined | true | Close 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. |
closeOnEsc | boolean | undefined | true | Close on Esc. The top layer of the stack closes, not the one lower in the document. |
scrollBehavior | GrModalScrollBehavior | 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). |
initialFocus | HTMLElement | null | undefined | null | The 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-model | boolean | — | Whether the window is open (`v-model`). The window has no state of its own: the parent opens and closes it. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The content of the window. With `scrollBehavior: 'inside'` only it scrolls. |
title | any | The heading of the window. It gives the window its accessible name through `aria-labelledby` — stronger than `ariaLabel`. |
description | any | An explanation for the window, linked to it through `aria-describedby`. |
header | any | A pinned header: with `inside` it stays in place, and only the body scrolls. |
footer | any | A pinned footer: in the same place as the header — outside the scrolling body. |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: boolean): void | — |
opened | (event: "opened"): void | — |
closed | (event: "closed"): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
open | () => void | Open the window: emits `update:modelValue` with `true` — the parent opens it. |
close | () => void | Close the window: emits `update:modelValue` with `false`. |
toggle | () => void | Toggle 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 type { GrModalProps, GrModalEmits, GrModalSize } from '@feugene/granularity/components/GrModal'Filedist/types/components/GrModal/GrModal.vue.d.ts
interface GrModalProps {
modelValue: boolean;
closeOnBackdrop?: boolean;
closeOnEsc?: boolean;
size?: GrModalSize;
scrollBehavior?: GrModalScrollBehavior;
ariaLabel?: string;
initialFocus?: HTMLElement | null;
}interface GrModalEmits {
(e: 'update:modelValue', value: boolean): void;
(e: 'opened'): void;
(e: 'closed'): void;
}Filedist/types/components/GrModal/config.d.ts
const grModalConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrModal">;Filedist/types/components/GrModal/grModalStyles.d.ts
type GrModalSize = GrOverlaySize;type GrModalScrollBehavior = 'outside' | 'inside';Filedist/types/components/GrModal/safelist.d.ts
const grModalSafelist: string[];Filedist/types/components/GrModal/index.d.ts
type GrModalInstance = ComponentExposed<typeof GrModalComponent>;Accessibility
Keys
EscTab / Shift+Tab
Full contract of the package: docs/keyboard.md ↗
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:
- there is a
#title(or aGrDialogheading deeper in the tree) — it gives the name througharia-labelledby; - there is no
#titlebut there is anariaLabel— the name is taken from the prop; - 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.
<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,#headerand#footerslots 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
closeOnEscandcloseOnBackdropdo 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.
<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.
Props in this example