GrModal

Package: @feugene/granularitycoreGroup: overlays

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

Machine-translated from the Russian original, not yet reviewed. Read the original

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-standardlyscrollBehavior 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.

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.

<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-modalcalc(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.

Playground 5

Loading…

Code
<GrModal />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefined
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 | undefinedtrue
closeOnEscboolean | undefinedtrue
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.
modelValuerequiredboolean

Slots

SlotTypeDescription
defaultany
titleany
descriptionany
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

EventTypeDescription
update:modelValue[value: boolean]
opened[]
closed[]

Methods / Expose

Methods / ExposeTypeDescription
open() => void
close() => void
toggle() => void

Examples 7

Bare modal flow

Basic Flow
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrModal } from '@feugene/granularity'

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <GrButton class="justify-self-start" @click="open = true">
      Open bare modal
    </GrButton>

    <!-- Модальный слой обязан иметь имя: заголовок здесь свёрстан в теле,
         поэтому имя отдаём пропом. Альтернатива — слот #title. -->
    <GrModal
      v-model="open"
      size="sm"
      aria-label="Bare modal shell"
    >
      <div class="grid gap-3">
        <div class="text-sm font-semibold text-[var(--gr-fg)]">
          Bare modal shell
        </div>
        <div class="text-sm text-[var(--gr-muted-fg)]">
          `GrModal` handles the overlay, focus trap and panel sizing — you assemble the content yourself.
        </div>
        <GrButton class="justify-self-start" @click="open = false">
          Close
        </GrButton>
      </div>
    </GrModal>
  </div>
</template>

Backdrop guard for critical flows

Try clicking the backdrop: the modal stays open until the user picks an explicit action.

Backdrop Guard
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrModal } from '@feugene/granularity'

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <div class="text-sm text-[var(--gr-muted-fg)]">
      Try clicking the backdrop: the modal stays open until the user picks an explicit action.
    </div>

    <GrButton variant="outline" class="justify-self-start" @click="open = true">
      Open guarded modal
    </GrButton>

    <GrModal
      v-model="open"
      :close-on-backdrop="false"
      size="md"
      aria-label="Draft protection"
    >
      <div class="grid gap-4">
        <div class="grid gap-1">
          <div class="text-sm font-semibold text-[var(--gr-fg)]">
            Draft protection
          </div>
          <div class="text-sm text-[var(--gr-muted-fg)]">
            Use this mode for wizard/confirm flows where a draft must not be lost by accident.
          </div>
        </div>

        <div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-muted)]/40 p-3 text-sm text-[var(--gr-muted-fg)]">
          Unsaved changes: pricing rules, SLA exceptions, recipients.
        </div>

        <div class="flex flex-wrap gap-3">
          <GrButton variant="outline" @click="open = false">
            Cancel
          </GrButton>
          <GrButton @click="open = false">
            Save draft
          </GrButton>
        </div>
      </div>
    </GrModal>
  </div>
</template>

Size variants for different payloads

Size Switcher
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrModal } from '@feugene/granularity'

const activeSize = ref<'sm' | 'lg'>('sm')
const open = ref(false)

function openWithSize(size: 'sm' | 'lg') {
  activeSize.value = size
  open.value = true
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap gap-3">
      <GrButton variant="outline" @click="openWithSize('sm')">
        Compact review
      </GrButton>
      <GrButton @click="openWithSize('lg')">
        Wide review
      </GrButton>
    </div>

    <GrModal
      v-model="open"
      :size="activeSize"
      :aria-label="`Active size: ${activeSize}`"
    >
      <div class="grid gap-4">
        <div class="flex items-center justify-between gap-3">
          <div>
            <div class="text-sm font-semibold text-[var(--gr-fg)]">
              Active size: {{ activeSize }}
            </div>
            <div class="text-sm text-[var(--gr-muted-fg)]">
              The same flow can scale for review, preview or a multi-column payload.
            </div>
          </div>
        </div>

        <div class="grid gap-3 sm:grid-cols-2">
          <div class="rounded-2xl border border-[var(--gr-brd)] p-3 text-sm">
            Summary block
          </div>
          <div class="rounded-2xl border border-[var(--gr-brd)] p-3 text-sm">
            Secondary block
          </div>
        </div>

        <GrButton class="justify-self-start" @click="open = false">
          Done
        </GrButton>
      </div>
    </GrModal>
  </div>
</template>

Imperative dialogs from an open modal

An open `GrModal` invokes the imperative `useDialogService`. The service mounts its own host in `document.body` on top of the modal, so closing confirm/alert/prompt does not close the source window — it stays open, and the user's decision is returned through a `Promise`.

Dialog Service
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrModal, useDialogService } from '@feugene/granularity'

const dialog = useDialogService()

const open = ref(false)
const log = ref<string[]>([])

function pushLog(message: string): void {
  log.value = [message, ...log.value].slice(0, 5)
}

// confirm -> Promise<boolean>. Открытая модалка остаётся на месте: сервис
// монтирует свой host в document.body поверх неё.
async function confirmFromModal(): Promise<void> {
  const ok = await dialog.confirm('Delete the selected draft irreversibly?', {
    title: 'Delete draft?',
    confirmText: 'Delete',
    confirmTone: 'danger',
    cancelText: 'Cancel',
  })
  pushLog(ok ? 'confirm -> confirmed (modal not closed)' : 'confirm -> cancelled (modal not closed)')
}

// alert -> Promise<void>. Одна кнопка, разрешается при закрытии.
async function alertFromModal(): Promise<void> {
  await dialog.alert('Changes were saved in the background. The settings window stayed open.', {
    title: 'Done',
    confirmText: 'Got it',
  })
  pushLog('alert -> closed (modal not closed)')
}

// prompt -> Promise<string | null>. Возвращает введённую строку или null.
async function promptFromModal(): Promise<void> {
  const name = await dialog.prompt('Enter a new preset name', {
    title: 'Rename preset',
    label: 'Preset name',
    placeholder: 'For example: Q3 pricing',
    value: 'Draft preset',
    confirmText: 'Save',
    cancelText: 'Cancel',
    required: true,
  })
  pushLog(name === null ? 'prompt -> cancelled' : `prompt -> "${name}"`)
}
</script>

<template>
  <div class="grid gap-3">
    <p class="text-sm text-[var(--gr-muted-fg)]">
      An open `GrModal` invokes the imperative `useDialogService`. The service mounts its own host in `document.body` on top of the modal, so closing confirm/alert/prompt does not close the source window — it stays open, and the user's decision is returned through a `Promise`.
    </p>

    <GrButton class="justify-self-start" @click="open = true">
      Open settings modal
    </GrButton>

    <GrModal
      v-model="open"
      :close-on-backdrop="false"
      size="md"
      aria-label="Workspace settings"
    >
      <div class="grid gap-4">
        <div class="grid gap-1">
          <div class="text-sm font-semibold text-[var(--gr-fg)]">
            Workspace settings
          </div>
          <div class="text-sm text-[var(--gr-muted-fg)]">
            Launch service dialogs straight from the open window — it stays in place after any of them is closed.
          </div>
        </div>

        <div class="flex flex-wrap gap-3">
          <GrButton variant="primary" tone="danger" @click="confirmFromModal">
            confirm
          </GrButton>
          <GrButton variant="outline" @click="alertFromModal">
            alert
          </GrButton>
          <GrButton variant="outline" @click="promptFromModal">
            prompt
          </GrButton>
        </div>

        <div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-muted)]/40 p-3 text-sm">
          <div class="mb-1 font-medium text-[var(--gr-fg)]">
            Results
          </div>
          <ul v-if="log.length" class="grid gap-1 text-[var(--gr-muted-fg)]">
            <li v-for="(entry, index) in log" :key="index">
              {{ entry }}
            </li>
          </ul>
          <div v-else class="text-[var(--gr-muted-fg)]">
            Empty for now — invoke any dialog above.
          </div>
        </div>

        <GrButton variant="outline" class="justify-self-start" @click="open = false">
          Close modal
        </GrButton>
      </div>
    </GrModal>
  </div>
</template>

Poppers inside a modal

Nested Poppers
<script setup lang="ts">
import { ref } from 'vue'

import {
  GrAutocomplete,
  GrButton,
  GrDropdown,
  GrFormField,
  GrModal,
  GrSelect,
  GrTooltip,
} from '@feugene/granularity'

// `GrDatePicker` из companion-пакета подставляется авто-импортом.
const open = ref(false)

const city = ref('berlin')
const cities = [
  { label: 'Berlin', value: 'berlin' },
  { label: 'Lisbon', value: 'lisbon' },
  { label: 'Tbilisi', value: 'tbilisi' },
]

const airport = ref('')
const airports = ['BER', 'LIS', 'TBS', 'AMS', 'IST'].map(code => ({ value: code, label: code }))

const departure = ref<string | null>('2026-08-12')
</script>

<template>
  <div class="grid gap-3">
    <GrButton class="justify-self-start" @click="open = true">
      Open form with poppers
    </GrButton>

    <!-- Панель, открытая изнутри окна, телепортируется в общий портал и лежит
         РЯДОМ с корнем окна, а не внутри него. Высоту ей задаёт стек слоёв:
         пока окно открыто, панель встаёт над ним. -->
    <GrModal v-model="open" size="md" aria-label="Trip details">
      <div class="grid gap-4">
        <div class="text-sm font-semibold text-[var(--gr-fg)]">
          Trip details
        </div>

        <GrFormField label="City">
          <GrSelect v-model="city" :options="cities" options-view="panel" />
        </GrFormField>

        <GrFormField label="Airport">
          <GrAutocomplete v-model="airport" :options="airports" placeholder="Start typing" />
        </GrFormField>

        <GrFormField label="Departure">
          <GrDatePicker v-model="departure" value-adapter="isoDate" locale="en-US" clearable />
        </GrFormField>

        <div class="flex items-center gap-3">
          <GrDropdown>
            <template #trigger="{ triggerProps }">
              <GrButton v-bind="triggerProps" variant="outline" size="sm">
                Actions
              </GrButton>
            </template>

            <template #content>
              <div class="grid gap-1 p-1 text-sm">
                <button class="rounded-[var(--gr-radius-control)] px-2 py-1 text-left hover:bg-[var(--gr-muted)]">
                  Duplicate trip
                </button>
                <button class="rounded-[var(--gr-radius-control)] px-2 py-1 text-left hover:bg-[var(--gr-muted)]">
                  Export as PDF
                </button>
              </div>
            </template>
          </GrDropdown>

          <GrTooltip text="Подсказка тоже поверх окна: её слой ниже модального">
            <GrButton variant="ghost" size="sm">
              Why so many pickers?
            </GrButton>
          </GrTooltip>
        </div>

        <div class="flex justify-end">
          <GrButton size="sm" @click="open = false">
            Done
          </GrButton>
        </div>
      </div>
    </GrModal>
  </div>
</template>

Windows stacked on top of each other

Каждое следующее окно меньше предыдущего, поэтому видно все четыре сразу. Открываются они по очереди, а объявлены статически — в портал попали в порядке создания.

Высота открытых слоёв, как её видит браузер:

Пока ничего не открыто.

Stack
<script setup lang="ts">
import { nextTick, ref } from 'vue'

import { GrButton, GrDialog, GrModal } from '@feugene/granularity'

/**
 * Лесенка окон: каждое следующее открывается изнутри предыдущего.
 *
 * Все четыре объявлены статически, то есть в контейнер портала попадают в
 * порядке **создания**. Высоту им даёт не он, а стек слоёв — иначе окно,
 * открытое позже, оказалось бы под соседом, хотя стек считает верхним именно
 * его и гасит остальные `inert`.
 */
/** Размеры по убыванию: так видно все четыре окна разом, а не только верхнее. */
const LEVELS = [
  { level: 1, size: 'xl' },
  { level: 2, size: 'lg' },
  { level: 3, size: 'md' },
  { level: 4, size: 'sm' },
] as const

const open = ref<boolean[]>([false, false, false, false])
const strategyOpen = ref(false)
const layers = ref<string[]>([])

/** Фактическая высота слоёв — читается из DOM, а не пересчитывается заново. */
async function readLayers() {
  await nextTick()
  layers.value = [...document.querySelectorAll<HTMLElement>('[data-gr-overlay-root]')]
    .map(root => root.style.zIndex)
    .filter(Boolean)
}

async function openLevel(index: number) {
  open.value[index] = true
  await readLayers()
}

async function closeLevel(index: number) {
  open.value[index] = false
  await readLayers()
}

async function closeAll() {
  open.value = [false, false, false, false]
  strategyOpen.value = false
  await readLayers()
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-2">
    <div class="grid content-start gap-3">
      <GrButton @click="openLevel(0)">
        Открыть лесенку
      </GrButton>
      <GrButton variant="outline" @click="closeAll">
        Закрыть всё
      </GrButton>

      <p class="showcase-demo-text text-sm">
        Каждое следующее окно меньше предыдущего, поэтому видно все четыре сразу. Открываются они
        по очереди, а объявлены статически — в портал попали в порядке создания.
      </p>
    </div>

    <div class="showcase-demo-panel grid content-start gap-2 rounded-[var(--gr-radius-lg)] border p-4">
      <p class="showcase-demo-text text-sm">
        Высота открытых слоёв, как её видит браузер:
      </p>
      <ul v-if="layers.length > 0" class="grid gap-1">
        <li v-for="(z, index) in layers" :key="index">
          <code class="showcase-demo-text text-xs">{{ index + 1 }}: {{ z }}</code>
        </li>
      </ul>
      <p v-else class="showcase-demo-text text-sm">
        Пока ничего не открыто.
      </p>
    </div>

    <GrModal
      v-for="(item, index) in LEVELS"
      :key="item.level"
      v-model="open[index]"
      :size="item.size"
    >
      <template #title>
        Окно {{ item.level }}
      </template>

      <div class="grid gap-3">
        <p class="showcase-demo-text text-sm">
          Уровень {{ item.level }}. Верхнее окно отвечает на клики, нижние ушли в
          <code>inert</code> — и лежат ниже по высоте, а не только в стеке.
        </p>

        <div class="flex flex-wrap gap-2">
          <GrButton
            v-if="index + 1 < LEVELS.length"
            size="sm"
            @click="openLevel(index + 1)"
          >
            Открыть окно {{ item.level + 1 }}
          </GrButton>
          <GrButton
            v-if="item.level === 1"
            size="sm"
            variant="outline"
            @click="strategyOpen = true; readLayers()"
          >
            Диалог поверх окна
          </GrButton>
          <GrButton size="sm" variant="outline" @click="closeLevel(index)">
            Закрыть
          </GrButton>
        </div>
      </div>
    </GrModal>

    <!--
      Тот самый случай из заявки потребителя: диалог объявлен раньше окон, а
      открывается позже. По порядку узлов в портале он оказался бы под ними.
    -->
    <GrDialog v-model="strategyOpen" title="Выбор стратегии" size="sm">
      <p class="showcase-demo-text text-sm">
        Диалог объявлен в шаблоне раньше окон, а открыт позже — и всё равно виден поверх.
        Раньше он попадал под окно и оставался невидимым, хотя именно он отвечал на клики.
      </p>

      <template #footer>
        <GrButton size="sm" @click="strategyOpen = false; readLayers()">
          Понятно
        </GrButton>
      </template>
    </GrDialog>
  </div>
</template>

Scrolling long content and lifecycle events

Not opened yet

Scroll Lifecycle
<script setup lang="ts">
import { computed, ref } from 'vue'

import type { GrModalScrollBehavior } from '@feugene/granularity'
import { GrBadge, GrButton, GrModal, GrSegmented } from '@feugene/granularity'

const open = ref(false)
const scrollBehavior = ref<GrModalScrollBehavior>('inside')
const phase = ref<'idle' | 'opened' | 'closed'>('idle')

const rows = Array.from({ length: 24 }, (_, index) => index + 1)

const phaseTone = computed(() => (phase.value === 'opened' ? 'success' : 'neutral'))

const phaseLabel = computed(() => ({
  idle: 'Not opened yet',
  opened: 'opened — enter animation finished',
  closed: 'closed — content is safe to unmount',
}[phase.value]))
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-3">
      <GrSegmented
        v-model="scrollBehavior"
        size="sm"
        :options="[
          { value: 'inside', label: 'inside' },
          { value: 'outside', label: 'outside' },
        ]"
      />
      <GrButton class="justify-self-start" @click="open = true">
        Open a long dialog
      </GrButton>
      <GrBadge :tone="phaseTone">
        {{ phaseLabel }}
      </GrBadge>
    </div>

    <GrModal
      v-model="open"
      size="md"
      :scroll-behavior="scrollBehavior"
      @opened="phase = 'opened'"
      @closed="phase = 'closed'"
    >
      <!-- Слот #title — рекомендуемый путь: он и виден, и даёт окну имя. -->
      <template #title>
        <div class="border-b border-[var(--gr-brd)] px-4 py-3 text-sm font-semibold text-[var(--gr-fg)]">
          Terms of use
        </div>
      </template>

      <div class="grid gap-2 p-4">
        <div class="text-sm text-[var(--gr-muted-fg)]">
          With `scrollBehavior="inside"` the panel scrolls itself and the title stays put. With `outside` the whole overlay scrolls.
        </div>
        <div
          v-for="row in rows"
          :key="row"
          class="rounded-xl border border-[var(--gr-brd)] px-3 py-2 text-sm"
        >
          Clause {{ row }}
        </div>
        <GrButton class="justify-self-start" @click="open = false">
          Accept
        </GrButton>
      </div>
    </GrModal>
  </div>
</template>

Component documentationAll components