← Blog
20 Oct 2026overlay loadingVueaccessibilityform UXasync state

Overlay Loading in Vue: Block Unsafe Form Actions Without Blocking the App

Build an accessible overlay loading state in Vue. Scope blocked actions, announce progress, protect form submissions, handle focus, and recover from failures.

Overlay Loading in Vue: Block Unsafe Form Actions Without Blocking the App

TL;DR

Use overlay loading only when an in-flight operation makes a defined form or panel unsafe to use. Cover that region, not the whole application; keep a visible status and any genuinely available recovery action outside the blocked content. In Vue, set pending state before the request, guard repeat submits, clear the layer on every outcome, and treat a timed-out save as unconfirmed, not automatically canceled.

A translucent layer is easy to draw. Deciding which actions it should interrupt is the engineering work. A dashboard refresh might leave neighboring cards usable; a form save might need to pause the fields whose values are being committed. I will build the latter as a Vue 3 example, then show how to test its focus, progress, and failure behavior. This is a UI pattern, not a claim that DOM Studio provides a component called LoadingOverlay. For a package lookup and a separate report-refresh example, see our loadingoverlay reference guide.

Table of contents

Before you start

Have a Vue 3 project using single-file components and a working, authenticated profile-save endpoint. The example assumes POST /api/profile accepts JSON { "name": "…" } and returns a successful HTTP response only when your application considers the save confirmed. That path is an illustrative application endpoint, not a DOM Studio API. Adapt the method, payload, authorization, response validation, server-side field errors, and idempotency policy to your service. Decide whether leaving the screen during a pending write is allowed. The browser cannot make that product decision for you.

For each request, write down its trigger, the smallest unsafe region, and the observable outcomes: confirmed success, explicit server rejection, unconfirmed network failure, and a wait that exceeds your product’s threshold. Our implementation uses native HTML and browser fetch() alongside Vue, without product-specific imports.

1. Choose an overlay only when a region must pause

Action: List what the user can still do during the request. If clicking Save again is the only hazard, a busy Save button and adjacent status are usually enough. If editing several interdependent fields would create a misleading second version while the first is being saved, cover those fields as a group. For a refreshing report with useful old values, consider a bounded panel overlay; for first-load content without old values, use a skeleton or reserved empty area. Background sync needs a status, not a page lock.

A full-screen overlay is defensible only when the entire workflow depends on the result. It is not a shortcut for managing one card’s pending state. When you have a real completed count or transferred bytes, a named determinate indicator can show it. Otherwise, give an indeterminate cue and state the actual task, such as “Saving profile.” Never synthesize a percentage from elapsed time. DOM Studio’s Progress reference demonstrates linear, segmented, radial, and indeterminate indicators; the overlay boundary remains your feature’s responsibility.

Expected result: You can point to the precise content made unsafe and name at least one unaffected action. If not: remove the overlay and start with button-level or inline feedback. For an initial load, confirm that there is meaningful content to retain before covering an empty panel.

2. Draw the blocked boundary before adding the layer

Action: Put the affected fields or panel contents in their own wrapper. Give its parent a positioning context and place the translucent layer across that wrapper only. Keep navigation, a persistent status message, and any genuinely usable cancellation action outside the blocked wrapper. If a dashboard has independently scrolling panels, check which element scrolls and where the overlay gets clipped at narrow widths.

A backdrop intercepts pointer input but does not, by itself, prevent a keyboard user from reaching controls behind it. Apply inert to the specific blocked content while pending: it removes its descendants from the tab order and accessibility tree. That also means do not put your status or Cancel control inside the inert subtree. Leave useful neighboring regions available. See MDN’s inert reference for the interaction implications.

Three-stage diagram showing a bounded loading overlay, an accessible status and cancel area, and a restored recovery state

This three-stage map shows the design decision: identify the local boundary, expose status or cancellation outside it, and restore the same working area after an outcome. It is not a suggestion to add a Cancel button to a write that cannot reliably be canceled.

Expected result: With the layer visible, a pointer and Tab both fail to activate covered controls, while safe controls elsewhere still work. If not: inspect the actual wrapper receiving inert, the stacking context, and the scroll container rather than raising a global z-index arbitrarily.

3. Give progress an honest, persistent message

Action: Mount an empty role="status" container before the operation starts, then update its text as the state changes. Put it outside the busy content. Mark the region being updated with aria-busy while work is pending, but do not treat that attribute as an interaction lock or a substitute for a message. The W3C status-message technique explains the pre-existing container and polite announcement; MDN’s aria-busy reference describes the separate update signal.

Use “Saving profile” at the start and “Profile saved” only after a confirmed response. A spinner may reinforce the visual state, but the text must stand on its own without color or motion. For uploads with actual progress, show a labeled progress indicator and announce meaningful milestones, not every tick: W3C’s progress-bar technique notes that changes to a progressbar’s values are not automatically live announcements.

The video below focuses on accessible progress bars and loading spinners. Use it to review the status cues, then apply the boundary and recovery rules in this guide to your own feature.

Expected result: Someone using a screen reader hears a specific start and outcome without focus being moved to a decorative spinner. If not: check that the status container exists before its text changes and that it is neither inert nor inside a busy live region suppressing its updates.

4. Keep focus on a usable control

Action: Decide where focus is when the form becomes unavailable. If the user submits from a field by pressing Enter, move focus to the still-available submit button before making the fields inert. Keep that button in the tab order with aria-disabled="true" while pending, and explicitly guard the submit handler. Unlike native disabled, aria-disabled only communicates the state; it does not suppress activation. This distinction is documented in MDN’s aria-disabled reference.

When the request finishes, leave focus on the same button and announce the result through the status region; make the fields available again. If the flow moves to a new screen, give the destination its own deliberate focus behavior. If a read-only refresh really supports cancellation, put its Cancel button outside the inert region and confirm that keyboard users can reach it. Do not label a POST request “canceled” merely because its browser connection closed: AbortController aborts a client-side operation, not necessarily work already accepted by the server.

Form overlay with a reachable cancel control and status area outside the blocked content

Expected result: Focus never disappears into the blocked fields, and it remains visible against the overlay. If not: reproduce the issue both by pressing Enter in a field and by clicking the submit button; the two paths can begin from different focus positions.

5. Wire the overlay to one guarded Vue request

Action: Keep the fields, overlay, button state, and status tied to the same pending ref. This standalone component uses a client-side 15-second threshold as an example, not a universal timeout. Replace it with your service’s policy. The POST has no Cancel button, because browser abort alone cannot promise that a save did not occur. Vue documents reactive refs and <script setup>, template refs, Boolean attribute bindings, and the unmount cleanup hook used here.

<!-- ProfileSave.vue: replace /api/profile with your authenticated endpoint -->
<script setup>
import { onBeforeUnmount, ref } from 'vue'

const name = ref('')
const pending = ref(false)
const message = ref('')
const fields = ref(null)
const submitButton = ref(null)
let controller = null
let timer = null
let requestId = 0
let removed = false

async function save() {
  if (pending.value) return

  // Enter from a field must not leave focus in the soon-to-be-inert region.
  if (fields.value?.contains(document.activeElement)) {
    submitButton.value?.focus()
  }

  const id = ++requestId
  const request = new AbortController()
  controller = request
  pending.value = true
  message.value = 'Saving profile'
  timer = setTimeout(() => request.abort(), 15000)

  try {
    const response = await fetch('/api/profile', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: name.value.trim() }),
      signal: request.signal,
    })
    if (removed || id !== requestId) return

    // A rejected response is not a confirmed save.
    message.value = response.ok
      ? 'Profile saved'
      : 'The service reported an error. Check your profile before retrying.'
  } catch {
    if (removed || id !== requestId) return
    // Network failure and abort do not prove the server stopped processing.
    message.value = 'Save unconfirmed. Check your profile before retrying.'
  } finally {
    clearTimeout(timer)
    if (!removed && id === requestId) {
      pending.value = false
      controller = null
      timer = null
    }
  }
}

onBeforeUnmount(() => {
  removed = true
  ++requestId
  clearTimeout(timer)
  controller?.abort()
})
</script>

<template>
  <form @submit.prevent="save">
    <div class="save-area" :aria-busy="pending">
      <div ref="fields" :inert="pending">
        <label for="profile-name">Name</label>
        <input id="profile-name" v-model="name" name="name" required>
      </div>
      <div v-if="pending" class="save-overlay" aria-hidden="true">
        <span class="save-ring"></span>
        <span>Saving profile</span>
      </div>
    </div>

    <button ref="submitButton" type="submit" :aria-disabled="pending">
      {{ pending ? 'Saving…' : 'Save profile' }}
    </button>
    <p role="status" aria-atomic="true">{{ message }}</p>
  </form>
</template>

<style scoped>
.save-area { position: relative; min-block-size: 5rem; }
.save-area input { display: block; max-inline-size: 100%; }
.save-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: .75rem;
  background: rgb(255 255 255 / .88);
}
.save-ring {
  inline-size: 1rem;
  block-size: 1rem;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin .9s linear infinite;
}
button[aria-disabled="true"] { cursor: wait; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .save-ring { animation: none; }
}
</style>

The ring is decorative to assistive technology because the separate mounted status gives the real message. Reduced-motion users retain that text even when the ring stops, consistent with MDN’s reduced-motion guidance. Check response.ok, since a received error response is not a successful save; see MDN’s Response reference. This sample does not parse field-level errors, confirm the server’s persistence semantics, supply server idempotency, or coordinate concurrent saves from other components. The server must enforce validation and duplicate-write protection. For richer field errors, our Vue form validation guide covers mapping server failures to controls.

Expected result: Repeated Submit attempts during a pending request do not start another request through this component. On success or failure, the layer clears and the values remain. If not: confirm no second entry point starts the same write elsewhere; client guards are local and cannot replace a shared or server-side concurrency policy.

6. Define recovery without claiming the request stopped

Action: Give every completion path a meaningful next action. A clear server validation rejection should return field-specific feedback when your API supplies it. A network disconnect or the example’s threshold leaves a more difficult question: the server may already have processed the POST. Show an unconfirmed result, let the user inspect the saved record or refresh authoritative data, and only then offer a safe retry. For important writes, use your server’s idempotency contract so a repeat cannot create an unintended second effect. MDN documents browser-side abort behavior; the server-side confirmation and retry contract is application-specific.

For a cancellable read-only dashboard refresh, it is reasonable to offer a reachable Cancel control and ignore an outdated response after cancellation. For an upload or long-running server job, expose cancellation only if your service actually supports it; otherwise offer a way to continue other work and return to job status. Keep previous results visible where they remain useful, but mark them as older data if that matters to a decision.

Expected result: A slow, rejected, or disconnected request never leaves an indefinite overlay with no explanation. If not: test the timeout, response rejection, navigation away, and repeated retry separately. A spinner disappearing is not proof of server success or rollback.

7. Test the interaction boundary, not just the spinner

Action: Run the same flow with a delayed response, an HTTP error, a dropped connection, and a fast success. Then repeat it at a narrow viewport, high zoom, reduced motion, and with keyboard and screen-reader navigation.

  • Press Enter from the name field. Focus should move to Save before the field becomes inert; the pending announcement should be understandable.
  • Try double-clicking and pressing Enter again. This component should send one request while pending, and the backend should independently handle repeat writes.
  • Tab across the covered form, neighboring dashboard content, and navigation. Only the unsafe region should be unavailable; safe actions should still be reachable.
  • Simulate a response arriving after a timeout or route unmount. It must not overwrite the newer outcome or update an unmounted view. Confirm the server’s actual saved state before retrying a POST.
  • Force a long translated status, increase text size, and narrow the viewport. The message and button should not be hidden behind the overlay or clipped by the panel.

Expected result: Pending, success, and unconfirmed outcomes remain distinguishable without relying on motion or color. If not: fix the interaction contract before choosing a different animation or adding a higher stacking layer.

Frequently asked questions

Does aria-busy prevent clicking or keyboard access?

No. It indicates content is being updated. Use native controls and, when a whole subtree truly must pause, narrowly scoped inert; keep the status outside it. MDN distinguishes the busy signal from the interaction effects of inert.

Should I show a percentage in a loading overlay?

Only if the service supplies meaningful measured progress. For an unknown-duration save, say what is happening and give a clear outcome instead. A progressbar’s changing value is not itself a live announcement, so report useful milestones if users need them. See W3C’s progress guidance.

Finished: a bounded, recoverable loading state

You now have an overlay that blocks one form’s unsafe fields, keeps its status and submit control accessible, and removes the layer when the request settles. The next useful step is to integrate it with your actual profile endpoint and server idempotency policy, then test a slow save from an input field with a keyboard and screen reader. For component-level progress visuals, explore the DOM Studio Progress playground without moving request ownership into the visual component.

Sources

Recommended Reads