You click a button, the request takes 80ms, and a spinner appears for a single frame. It doesn't look fast. It looks broken.

Some context first. I started vue-smart-loading-kit because of skeletons. In my Vue projects I used Vuetify's skeleton loader, but I couldn't get the colors and animation I wanted, and its presets didn't match a lot of my real layouts. I kept hand-writing CSS to make a skeleton look like the actual component.

At some point I asked myself: why not turn the real layout itself into its skeleton, with CSS only and without touching the DOM? That became v-skeleton, which I wrote about in my previous article. A reader there spotted a bug with SVG icons in the comments, and I shipped the fix in 0.3.4 the same day.

But a skeleton that looks right is only half of a good loading state. The other half is timing.

The naive version

<Spinner v-if="loading" /> 

The loader follows loading exactly. That's the problem: users don't care how long loading was true. They care about what they saw. An 80ms request shows a spinner for 80ms, which is one flash.

Adding a delay creates a new problem

The obvious fix is to wait a little before showing the loader. Say 200ms. Now the 80ms request shows nothing, which is what we want.

But take a request that lasts 250ms. The spinner appears at 200ms and disappears at 250ms: it's on screen for 50ms. The flash is back, just later. I call that a blink.

Two numbers

When I built SmartLoader, the timing came down to two numbers:

  • delay (200ms): a load must last this long before any loader appears. Fast loads show nothing at all.
  • minDuration (500ms): once a loader is visible, it stays at least this long. It never just blinks.

One more rule: if loading starts again while the loader is still up, the loader simply stays.

Here's what that does to a few loads:

The load takes What the user sees
120ms No loader. The content just appears.
300ms Loader from 200ms to 700ms
2s Loader from 200ms to 2s

Timeline with a 200ms delay and 500ms minimum duration: a 120ms load shows no loader, a 300ms load shows it from 200ms to 700ms, and a 2s load shows it from 200ms to 2s

A simplified version of the idea

This isn't the package's code, just the core logic in a few lines:

import { ref, watch, onScopeDispose, type Ref } from 'vue' export function useDelayedLoadingSimple( loading: Ref<boolean>, delay = 200, minDuration = 500, ) { const show = ref(false) let delayTimer: ReturnType<typeof setTimeout> | undefined let minTimer: ReturnType<typeof setTimeout> | undefined let shownAt = 0 watch( loading, (isLoading) => { if (isLoading) { clearTimeout(minTimer) // restarted: keep the loader if it's up if (show.value) return delayTimer = setTimeout(() => { show.value = true shownAt = Date.now() }, delay) } else { clearTimeout(delayTimer) // done before the delay: never show if (!show.value) return const remaining = minDuration - (Date.now() - shownAt) minTimer = setTimeout(() => (show.value = false), Math.max(0, remaining)) } }, { immediate: true }, ) onScopeDispose(() => { clearTimeout(delayTimer) clearTimeout(minTimer) }) return show } 

In the package this is useDelayedLoading. loading and both options can be refs or getters:

import { useDelayedLoading } from 'vue-smart-loading-kit' const showLoader = useDelayedLoading(loading, { delay: 200, minDuration: 500 }) 

Timing is only half of it

A real loading state needs more than a boolean, so SmartLoader wraps the content and also:

  • keeps the content's height while the loader stands in for it, so the page below doesn't jump;
  • sets aria-busy="true" as soon as loading starts, and announces a label to screen readers while the loader is visible;
  • shows an error with a Try again button that emits retry. During a retry the error stays on screen until the loader appears, so nothing flashes in between.
<SmartLoader :loading="loading" :error="error" @retry="loadUsers"> <UserList :users="users" /> </SmartLoader> 

It has three modes:

Mode While loading Good for
replace (default) Swaps the content for a skeleton First load, no data yet
overlay Keeps the content dimmed and inert, with a spinner on top Refreshing data, submitting forms
skeletonize Turns the content into its own skeleton with v-skeleton First load with placeholder data

Takeaway

If you remember one thing: don't bind a loader directly to loading. Put a short delay in front of it and a minimum duration behind it.

The package is on npm as vue-smart-loading-kit, and the live demo lets you play with both numbers.

What delay do you use for loaders in your apps, or do you show them right away?