Skip to content

NbModal is a dialog overlay: a surface that sits above the page, takes the whole screen out of play until it is answered, and hands the user back where they were. It uses <Teleport> to render at the body level, so its stacking is correct no matter how deep in the tree it is written.

It is one of exactly two dialogs the library ships. The other is NbConfirm, and it is the only shape a destructive confirmation may take. If you are about to build a confirmation out of NbModal, stop and read When not to use.

This page taught the wrong confirmation, and shipped it

Until this release the section below this one demonstrated a destructive confirmation assembled by hand: variant="primary" on a Confirm button, a title-case "Confirm Action" heading, the body "Are you sure you want to continue?", and an NbPanel with inline padding inside #footer fighting the right-aligned grid Modal.vue already puts there. One audited application's markup is a near copy of it, primary-styled destructive button included.

The correction is not a corrected footer. It is useConfirm(). What is left here is the footer contract, which that example also got wrong, and which every dialog on this page needs.

vue
<template>
  <NbButton @click="open = true">Rename workspace</NbButton>
  <NbModal :open="open" @close="open = false">
    <template #header>Rename workspace</template>
    <NbTextInput v-model="name" label="Name" />
    <template #footer>
      <NbButton size="lg" variant="ghost" @click="open = false"
        >Cancel</NbButton
      >
      <NbButton size="lg" variant="primary" @click="rename">Rename</NbButton>
    </template>
  </NbModal>
</template>

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

const open = ref(false)
const name = ref('')
</script>

Anatomy ​

Seven parts, and every rule further down this page is about one of them. The class names are the ones Modal.vue actually renders, so they are also what you will find in a screenshot, a DOM inspector or a failing snapshot.

#PartClassComes fromWhat it is for
1Scrim.nb-modal--overlayThe componentFills the viewport with --nb-c-scrim, holds the 20px margin every size keeps, and emits close on a click unless closeOnOverlay is off
2Dialog box.nb-modal--contentThe componentrole="dialog" (or alertdialog), aria-modal="true", tabindex="-1". Also carries .nb-modal--content--{size}, which is where the caps live
3Header.nb-modal--headertitle or #headerRendered only when one of the two is given. Without it the dialog has no accessible name and no close control
4Title.nb-modal--titletitle or #headerCarries the generated id the dialog is aria-labelledby-ed to, unless you override it with labelledBy
5Close control.nb-modal--closeThe componentThe X. Always present when there is a header, always emits close, disabled by closeDisabled
6Body.nb-modal--bodyThe default slotThe only scrolling region. Becomes a labelled, focusable role="region" while it overflows
7Footer.nb-modal--footer#footerAn NbGrid with justify="end" and distributed, rendered only when the slot has content. It is an action bar already, see below

Two of those are not yours to build. The header lays out its own title and X, and the footer is already a grid, so anything you put inside either one is a child of an existing layout rather than the start of a new one. That is the single most common way this component is misused.

When to use ​

A dialog costs the user everything else on the screen. It is worth that when the task is short, is about the thing behind it, and has an end. Four cases pass that test.

A short edit that belongs to what is behind it ​

Renaming a record, moving it, changing its four settings, inviting one person. The user is looking at the object and wants to change something about it without losing sight of where it sits in the list. Up to four inputs is the working ceiling; past that, see the first row of When not to use.

A choice from a set too large for a menu ​

Picking an icon, a template, a target project, a file from a browser. A menu of eight is a menu. A searchable, filterable, scrollable set of eighty is a task, and a task with a cancel needs a surface with a cancel.

A detail that has to be read against its context ​

A run's log, a diff, a version's contents, side by side comparison. The page behind is the reason the detail makes sense, so a route that replaces it costs more than a dialog that covers it. size="immersive" exists for exactly this (see Sizes).

A step that has to be finished or abandoned before anything else moves ​

A two-factor challenge, a required credential before an import, a mandatory field a bulk action needs. The interruption is the point: the workflow genuinely cannot continue in either direction until this is answered.

The test, in one line

The user opened it, it fits on one screen, and answering it (either way) is the end of it. If any of those three is false, one of the surfaces below is the right one.

When not to use ​

Every row here is something the fleet actually built as a modal. The right-hand column is the reason it hurt, not a matter of taste.

What you haveUse insteadBecause
A destructive confirmationuseConfirm()Every hand-built one in the fleet lost the same five things. See below
More than four inputs, or a form that scrollsA route (Forms)A form scrolling inside a scrim is a page wearing a costume, with worse focus and no URL
The result of something that just finisheduseToast()A result is news, not a question. Nobody has to answer it, so nobody should have to dismiss it
A condition that stays true while the page is openNbBanner variant="callout"A standing fact that interrupts once and vanishes has been told to the user exactly once, at random
A failure that belongs to the page or the formNbBanner variant="inline", in placeThe user has to see the failure and the thing that failed at the same time
Something wrong with one fieldNbMessageThe field is where the fix happens
Properties of a selected object, edited repeatedlyNbShellPanel (Inspectors)Selecting the next object should not mean reopening a dialog. The object and its editor stay on screen together
A whole task with its own sub-stepsA route, or NbStepper inside oneIf it deserves a back button, it deserves a URL
Marketing, onboarding, a tour, a "what's new"Nothing, or NbWalkthroughA dialog nobody opened is an interruption, not a conversation. Dialogs are user-initiated
Anything the user should be able to copy out of, or return toA route or a panelA dialog is dismissed by Esc, by the scrim and by a stray click. It is the wrong home for anything worth keeping

Never assemble a confirmation out of NbModal ​

The audit read twelve applications and found seven different confirmation mechanics, plus sixteen or more destructive actions with no confirmation at all: an environment and every entry, release and key inside it deleted behind a window.confirm; an OAuth client secret regenerated on one unguarded click; a localStorage.clear() in a command palette; another user's comment deleted with nothing asked.

The hand-built ones all lost the same five things, and none of them are decorations:

  1. Initial focus on cancel, so a stray Space does not commit.
  2. role="alertdialog", so the consequence is read on entry rather than on request.
  3. The pending lock, so a fired delete cannot be fired twice or dismissed as if it had not happened.
  4. variant="danger" on the commit, so the destructive button is not the same button as Save.
  5. The record's own name in the body, so "delete this environment?" becomes "delete production?".

useConfirm() is one call and owns all five:

ts
import { useConfirm } from '@nubisco/ui'

const confirm = useConfirm()

if (
  await confirm({
    title: 'Delete environment',
    subjectLabel: 'Environment',
    subject: environment.name,
    message: 'Every entry, release and API key in it is deleted.',
    confirmLabel: 'Delete environment',
  })
) {
  await api.deleteEnvironment(environment.id)
}

The whole decision (when to confirm, when confirming is itself the bug, what the buttons say, how big a guard the blast radius earns) is settled once in Dialogs and destructive confirmation. It is not restated here.

The four dialog shapes ​

NbModal has one visual variant and five sizes, so the taxonomy that matters here is not a look, it is a dismissal contract: how the user gets out, and what the footer must contain for that to be honest. Pick the row first, then the size.

ShapeWhat it isWays outFooterBuild it with
ConfirmationAn irreversible or expensive act, answered yes or noEsc, Cancel, scrim, X. All four mean no. None mean yesCancel, then a variant="danger" commituseConfirm(), never NbModal
FormA short edit that ends in a commitSame four, but a dirty form asks before discardingCancel, then a variant="primary" commitNbModal plus the validation rules
DetailSomething to read, compare or copy from, that changes nothingSame four, all identical, all harmlessOne Close, or no footer at allNbModal, usually lg, xl or immersive
Blocking stepThe workflow cannot continue in either direction until this is answeredOnly the footer, and only when abandoning truly is impossibleAn explicit exit and an explicit commitNbModal with :close-on-overlay="false"

Two rules hold across all four.

Every dialog has an exit, and it is visible. A blocking step is the only shape allowed to switch off the scrim and Esc, and the price of switching them off is that the footer must then carry the way out in words ("Sign out", "Cancel import"). A dialog with no Esc, no scrim dismissal and no exit button is a trap, and it is what closeDisabled produces if you set it and forget it, because it silently disables the Esc route too (see Work in flight).

A dialog with nothing to decide is not a dialog. If the user's only possible answer is "fine", the message is news: useToast() for a moment, NbBanner for a condition. An OK-only dialog is an interruption that charges a click for nothing.

This is the part of the old example that was wrong in a way you can see, and the reason a screenshot of one audited product has a single half-width button floating in the middle of its dialog.

Modal.vue renders the #footer slot inside a grid that is already a right-aligned, distributed action bar:

vue
<NbGrid
  is="footer"
  v-if="$slots.footer"
  justify="end"
  class="nb-modal--footer"
  distributed
>
  <slot name="footer" />
</NbGrid>

Three consequences follow, and they are the whole contract:

  • distributed gives every direct child flex: 1. Two buttons become two equal columns filling the footer, edge to edge. That is the bar, and it is why footer buttons carry size="lg".
  • A single child is capped at half the width (:only-child { max-width: 50% }) and pushed to the end by justify="end". One button is deliberately a half-width button on the right, not a full-bleed one.
  • A wrapper counts as one child. Wrapping your buttons in an NbPanel or an NbGrid makes the whole group the only child, so the group is squeezed to 50%, the padding you added fights the bar's own edges, and the nesting buys nothing that was not already there.

So the buttons go in bare. Cancel first, commit second, both size="lg".

Don'tDo
#footer > NbPanel > NbGrid > buttons#footer > buttons
Inline style="padding: 12px" on a footer wrapperNothing. The bar owns its own edges
variant="primary" on a destructive commitvariant="danger", and reach for useConfirm() before NbModal
"Confirm" and "Cancel" as the pairThe verb and its exit: "Delete environment" / "Cancel"
A third and fourth buttonTwo. Three only when the third is a genuinely different outcome

Content ​

The words in a dialog carry more weight than anywhere else in the product, because the user cannot look anything up while it is open.

  • The title names the action, in sentence case. "Rename workspace", "Delete environment", "Choose a template". Not "Confirm Action", not "Are you sure?", not "Warning".
  • The body says what will happen and to what. One sentence, present tense, naming the record: "Every entry, release and API key in production is deleted." A body that only restates the title is noise the user learns to skip.
  • The buttons say the verb. The commit repeats the title's verb, the exit is "Cancel". OK, Yes, Done and Dismiss say nothing about what is about to happen, so none of them belong on a commit button.
  • Do not use a dialog to apologise. "Something went wrong" in a dialog is a failure the user must dismiss before they can look at the thing that failed. Put it in place, in an inline banner.

Tone, capitalisation and sentence shape are the fleet's, not this component's: Writing style is the source.

Behaviour ​

Trigger ​

A dialog is opened by the user, from a control they pressed. A button, a menu item, a row action, a command palette entry, activated by click, by Enter or by Space. That control is also where focus returns when the dialog closes, which is the practical reason the rule exists: a dialog with no trigger has nowhere to give focus back to, and a keyboard user lands at the top of the document.

Three things are therefore not triggers, and all three appeared in the audit:

  • A timer or a route entering. A dialog nobody opened is an interruption. If the page has something to say on arrival, it is an NbBanner variant="callout", which stays true for as long as the page does.
  • A background event finishing. A sync completing while the user is typing somewhere else is a toast, not a dialog. It stole the caret otherwise.
  • A failed request from a page the user has already left. Report it where it belongs, or drop it. Do not open a dialog over whatever they are looking at now.

The one legitimate exception is a session that has ended: the application cannot continue and there is genuinely a decision to take. That is a blocking step, and it still needs the exit rule above.

Opening and closing ​

The modal is controlled. :open is yours, and close is a request, not an event that already happened:

vue
<NbModal :open="open" @close="open = false" />

close is emitted by the header close button, by Esc, and by a click on the scrim unless closeOnOverlay is false. There is one meaning for all three: the user asked to leave without committing. If leaving would lose work, do not silence the routes; ask, and let the answer decide (Forms, rule 17).

Esc answers the topmost dialog only ​

Every open instance listens on document, so before this was fixed a stack of two dialogs answered one keypress twice and the user lost both. The topmost surface is the last aria-modal element in document order, which for teleported overlays is the one painted on top. A wrapper that wants to answer the key itself sets :close-on-escape="false", which is what NbConfirm does.

The page behind does not scroll ​

Opening takes a counted share of a single page-scroll lock shared with NbCommandPalette and NbSpinner's page overlay. The count matters: three components writing body.style.overflow independently used to leave the page permanently unscrollable with nothing on screen. The original value is read once when the first owner arrives and put back once when the last one leaves, so an application that sets overflow: hidden on the body for its own layout keeps it.

A scrollable body is reachable from the keyboard ​

When the body actually overflows it becomes role="region" with tabindex="0", labelled by the dialog title, so it can be scrolled without a pointer. It is marked only while it overflows, so a two-line dialog does not grow a stop in its tab order for nothing. Overflow is watched with a ResizeObserver on the box and a MutationObserver over its contents, because a body that fits at open time stops fitting when the window is resized, when slot content arrives from a fetch, or when a translated string wraps onto another line.

Validation and failure ​

A dialog that closes on a failed submit has thrown the user's typing away and told them about it somewhere they can no longer see. Three rules, in the order the failures arrive.

A form that fails validation keeps the dialog open. Do not close, do not clear, do not toast. Mark the offending field with NbTextInput's error prop and move focus to the first invalid one, so the fix happens where the mistake is:

vue
<template>
  <NbModal :open="open" title="Invite member" @close="close">
    <NbTextInput
      v-model="email"
      label="Email"
      :error="emailError"
      @update:model-value="emailError = ''"
    />
    <template #footer>
      <NbButton size="lg" variant="ghost" @click="close">Cancel</NbButton>
      <NbButton size="lg" variant="primary" @click="submit"
        >Send invite</NbButton
      >
    </template>
  </NbModal>
</template>

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

const open = ref(false)
const email = ref('')
const emailError = ref('')

function submit() {
  if (!email.value.includes('@')) {
    emailError.value = 'Enter a full email address'
    return
  }
  // ... commit, then close
}
</script>

A server-side failure goes in an inline banner inside the body. NbBanner with variant="inline" and status="error", at the top of the body, above the fields, where the retry is one press away. Never a second dialog over the first, and never a toast: toasts paint above the scrim (see Toast, when not to use), so a failure raised from behind an open dialog covers the surface the user is reading and then removes itself.

vue
<NbModal :open="open" title="Invite member" :busy="saving" @close="close">
  <NbBanner v-if="failure" variant="inline" status="error" :title="failure">
    Check the address and try again.
  </NbBanner>
  <NbTextInput v-model="email" label="Email" />
</NbModal>

A body that is still fetching opens anyway. Delaying the open so the content can arrive gives the user a button that appears not to have worked, which is how a fleet application ended up with a double-click on every row action. Open immediately and put NbSkeleton where the content will be, at the shape the content will have, so nothing jumps when it lands:

vue
<NbModal :open="open" title="Run log" @close="open = false">
  <NbSkeleton v-if="loading" variant="text" :lines="6" label="Loading the run log" />
  <pre v-else>{{ log }}</pre>
</NbModal>

If the fetch comes back with nothing, say so with NbEmptyState inside the body. A blank dialog and a loading dialog must not look the same: one audited application renders a single .empty-text for both, so a failed fetch reads as "no contacts yet".

Work in flight ​

Once the commit has been fired, the dialog owes the user three things, and they are not optional.

  • The pending affordance goes on the commit button, not over the body. NbButton has loading for exactly this. An overlay spinner across the dialog hides the values the user just typed at the moment they most want to check them, and it makes the footer look pressable while it is not.
  • The commit disables itself while it is busy. A second press on a fired delete is a second delete. :disabled="saving" on the commit, and :loading="saving" for the spinner.
  • busy and closeDisabled are set together, never one without the other. busy marks the dialog aria-busy="true", which is the half a screen reader hears; closeDisabled disables the X, which is the half everyone else sees. Set only busy and the user can dismiss a delete that is already running; set only closeDisabled and the wait is invisible to assistive technology.
vue
<NbModal
  :open="open"
  title="Delete environment"
  :busy="saving"
  :close-disabled="saving"
  :close-on-overlay="!saving"
  @close="open = false"
>
  <p>Every entry, release and API key in {{ name }} is deleted.</p>
  <template #footer>
    <NbButton size="lg" variant="ghost" :disabled="saving" @click="open = false">
      Cancel
    </NbButton>
    <NbButton size="lg" variant="danger" :loading="saving" :disabled="saving" @click="commit">
      Delete environment
    </NbButton>
  </template>
</NbModal>

Two things that example is honest about. closeDisabled also switches off the Esc route, because Modal.vue's key handler returns early on it, so the dialog above has no keyboard exit at all while saving is true. That is correct for an irreversible commit already in flight and wrong for anything else, which is why it is bound to saving and not left on. And once every control in the dialog is disabled, the browser drops focus onto <body>, outside the dialog. NbModal recovers it onto the dialog box itself, which carries tabindex="-1" for exactly this: see Focus is handled.

A confirmation gets all of this without you writing any of it. This example exists to show the shape for the dialogs that are not confirmations.

Sizes ​

size caps both dimensions of the dialog: sm (520px wide, up to 72% of the viewport height), md (720px, 84%), lg (960px, 96%), xl (1280px, 98%) and immersive (1600px, the full height inside the margin). Height stays content-driven below the cap, so short dialogs never show empty space; long content scrolls inside the body once the cap is reached.

No size is a fixed box. The dialog is always width: 100% inside an overlay that keeps a margin all round, so every cap behaves as min(cap, viewport - margin): on a narrow screen immersive and sm are the same width, and neither can overflow the viewport or push the page into horizontal scroll.

vue
<template>
  <NbModal :open="open" size="lg" title="Large dialog" @close="open = false">
    <p>Long content scrolls inside the body.</p>
  </NbModal>
</template>

Choosing between lg, xl and immersive ​

The two larger steps solve different problems, which is why both exist.

Reach for xl when a dialog is content-heavy but still a dialog: a form with two columns, a detail panel, a preview beside a description. It is a bigger box, not a different kind of surface.

Reach for immersive when the content is a comparison and the columns are the point: several candidates side by side, a diff, a wide table you actually have to read across. At lg the same content wraps into cramped columns and buries the differences in inner scrolling, which is the failure this size exists to fix.

immersive is deliberately not full-screen. It keeps its margin, so the page behind stays visible and the dialog still reads as something you are inside of rather than somewhere you navigated to. If a task genuinely owns the whole screen, it wants a route, not a modal.

Open the same four-column comparison at each size to see the difference:

vue
<template>
  <NbModal :open="open" size="immersive" title="Region comparison">
    <NbGrid dir="row" gap="md" align="stretch">
      <NbPanel v-for="region in regions" :key="region.id">
        <!-- one column per region -->
      </NbPanel>
    </NbGrid>
  </NbModal>
</template>

Accessibility ​

What the component does for you:

  • The dialog box is aria-modal="true" with role="dialog", or role="alertdialog" when you pass it. Use alertdialog only for an interruption that reports a condition or demands a decision that cannot wait; it makes a screen reader read the description on entry instead of waiting to be asked.
  • It is named automatically by its own title when title or the #header slot is used. Pass labelledBy when the accessible name lives on an element inside your header slot, and describedBy when a specific element is the description.
  • The box carries tabindex="-1", so there is always somewhere legitimate for focus to sit even when every control inside is disabled.
  • Esc closes the topmost dialog only, and closeOnEscape turns it off for a wrapper that answers the key itself.
  • An overflowing body is a labelled, focusable role="region", so it can be scrolled from the keyboard.
  • busy and closeDisabled express "work is in flight" in ARIA rather than only in colour.

Focus is handled ​

NbModal holds keyboard focus for as long as it is open. You do not wire this up, and there is nothing to copy into your application:

  • Focus moves in on open. The first focusable control takes it, or the dialog box itself when the dialog has no controls at all.
  • Tab and Shift+Tab cycle inside the dialog. They do not reach the page behind the scrim, which behind a delete dialog is the list the user is deleting from.
  • Focus comes back to the trigger on every route out, including Esc, the close button and the scrim. A trigger that has since been unmounted (the row action of a row this dialog just deleted) is skipped rather than throwing.
  • Focus that leaves by a route no keystroke explains is recovered: a click on the scrim, a focus() from application code, or a control disabled out from under the caret.
  • A popup teleported out of the dialog still counts as inside it. An open NbSelect list, NbMenu or NbDatePicker calendar is a sibling of the dialog in the DOM, not a descendant, and the trap knows it.
  • Only the topmost dialog answers. A confirmation raised from inside a dialog takes the keys; the surface underneath keeps its state.

Nothing behind the scrim is marked inert, because these overlays teleport to <body> next to an application root the library does not own. The trap is what stands in for it.

Directing initial focus ​

The default landing is the first focusable control, which in a dialog with a header is the close button. That is right for a dialog you only read and wrong for a form, so point initialFocus at the field:

vue
<NbModal :open="open" title="Invite teammate" initial-focus="input" @close="open = false">

initialFocus takes a CSS selector resolved inside the dialog, or a function returning the element, for a component ref: NbTextInput exposes its native element as nativeEl, so it is :initial-focus="() => firstFieldRef?.nativeEl".

Three rules it does not decide for you:

  1. Never point it at a destructive control. Focus Cancel, or leave the default. A stray Space on an autofocused Delete is a deleted record. Focus Cancel, or the dialog box itself.
  2. Focus a text control only when typing is the point. In a read-only detail dialog, leave it alone so a screen reader starts at the title instead of halfway down.
  3. A confirmation is still NbConfirm, not an NbModal you focused carefully. It runs its own trap with extra rules for its pending state and its type-to-confirm gate, and it sets :trap-focus="false" so the two do not pull against each other.

Upgrading from a hand-rolled trap

Earlier versions of this page shipped a useDialogFocus composable to copy into your application, because NbModal did not do any of the above. If you copied it, delete it: the library now does this. If you would rather keep your own for a dialog with unusual requirements, set :trap-focus="false" on that dialog and NbModal will keep its hands off focus entirely, exactly as before.

The demo below is a plain NbModal with no focus code of its own. Open it from the keyboard and hold Tab: focus cycles close, name, notes, Cancel, Save and back to close, never reaching the page behind. Close it any way you like, including Esc and the scrim, and the trigger button is focused again. The readout is document.activeElement.

The general rules those come from are in Keyboard and Dialogs, rule 9.

Known gap: motion

The open and close transition is a fixed 200ms of opacity and a small scale, and it is not currently reduced under prefers-reduced-motion: reduce. NbConfirm sets its own duration to zero for that preference. Until NbModal does the same, an application that cares can neutralise it globally:

css
@media (prefers-reduced-motion: reduce) {
  .nb-modal-enter-active,
  .nb-modal-leave-active,
  .nb-modal-enter-active .nb-modal--content,
  .nb-modal-leave-active .nb-modal--content {
    transition-duration: 0ms !important;
  }
}

Known gap: the close button's name

Modal.vue hardcodes aria-label="Close" on the header X. There is no prop for it, so it cannot be translated, which is an asymmetry with NbToast, where closeLabel exists and is documented. A non-English application either lives with one English word in the dialog or renders its own close control in the #header slot until a prop lands.

Props ​

PropTypeDefaultDescription
openbooleanfalseWhether the dialog is on screen. Controlled: you set it back to false on close
titlestringundefinedHeader title, an alternative to the #header slot
size'sm' | 'md' | 'lg' | 'xl' | 'immersive''md'Caps the dialog width and height (see Sizes)
closeOnOverlaybooleantrueWhether a click on the scrim emits close
closeOnEscapebooleantrueWhether Esc emits close. Only the topmost dialog answers the key either way, and closeDisabled suppresses it independently
role'dialog' | 'alertdialog''dialog'alertdialog for an interruption whose description should be announced on entry
labelledBystringundefinedId of the element naming the dialog. Defaults to the built-in title, so the common case needs nothing
describedBystringundefinedId of the element describing the dialog, usually its body
busybooleanfalseMarks the dialog aria-busy while work it started is in flight. Set it with closeDisabled, never alone
closeDisabledbooleanfalseDisables the header close control (disabled and aria-disabled) and switches off the Esc route: the key handler returns early on it, so a dialog with it set and no footer exit has no keyboard way out

No prop for the close button's name

The header X carries a hardcoded aria-label="Close". Unlike NbToast, which exposes closeLabel, there is nothing to translate it with. Render your own control in #header if that matters to you.

Slots ​

SlotDescription
headerTitle area. Rendering either this or title is what gives the dialog its accessible name
defaultBody. Scrolls, and becomes a focusable labelled region when it overflows
footerAction bar. Put buttons in bare: the slot is already inside a right-aligned, distributed NbGrid

Events ​

EventPayloadDescription
closenoneThe user asked to leave: the close button, Esc, or the scrim (unless closeOnOverlay is off)

Exposed ​

Both are getter functions, not refs and not elements. defineExpose publishes dialogEl: () => contentRef.value, so the call form has parentheses:

ts
const modalRef = ref<{
  dialogEl: () => HTMLElement | null
  bodyEl: () => HTMLElement | null
} | null>(null)

modalRef.value?.dialogEl()?.focus()
ExposedCallReturnsDescription
dialogElmodalRef.value?.dialogEl()HTMLElement | nullThe dialog box, for a wrapper that manages focus (this is how NbConfirm finds it)
bodyElmodalRef.value?.bodyEl()HTMLElement | nullThe scrolling body element

Both return null until the dialog is open and rendered. NbModal handles its own focus (see Focus is handled), so these are for a wrapper that needs the element for something else.

Z-index ​

The overlay uses --nb-zindex-modal. Components that render inside a dialog and must escape its DOM subtree (NbSelect's list, NbMenu, NbDatePicker's calendar) use the --nb-zindex-modal-* tier. Toasts sit above both, deliberately. See Z-index.