If you've spent any time watching real users interact with a Joomla site, you've probably seen it happen. They fill out a form, click submit, and then - nothing. No feedback, no spinner, no progress indicator bar, no indication that anything is happening at all. So they click again. Maybe a third time. By the time the page finally loads they've submitted the form three times, or navigated away in frustration, or simply concluded that your site is broken.
This isn't a Joomla bug. It's a UX gap. Joomla has no native loading indicator for form submissions or page transitions, and most solutions in the Joomla ecosystem reach for an extension to solve it. You don't need one.
What you need is about 100 lines of vanilla JavaScript, a few lines of CSS, and a place to put them.
The Problem in Plain Terms
When a user clicks a link or submits a form, the browser sends a request and waits for a response. During that wait - which could be milliseconds or several seconds depending on server load, network conditions, and what the page is doing - the user sees nothing change. No feedback. No confirmation that their action registered.
This is a solved problem. Operating systems figured it out decades ago. When your computer is busy, it tells you - Microsoft Windows replaced your cursor with an hourglass, and Apple Mac OS gave you the now-iconic spinning beach ball. Every user who has ever sat in front of a computer understands immediately what these load feedback animations mean: something is happening, please wait.
The web never adopted this convention consistently. Browsers don't automatically show a busy cursor during page loads or form submissions, and most web frameworks - Joomla included - don't add one. The result is a UX gap that's easy to overlook as a developer because you know what's happening behind the scenes. Your users don't.
The solution is equally well-understood: show the user something is happening. It doesn't have to be elaborate. It just has to exist.
The Approach
We're going to add a CSS class to the <body> tag the moment a user clicks a navigable link or submits a form. That class will trigger a busy cursor on desktop, and a blocking overlay on mobile touch devices. When the new page loads - or if the user hits the back button - the class is removed.
No framework. No extension. No dependencies beyond Font Awesome, which you probably already have.
The CSS
The desktop side of this couldn't be simpler:
body.busy,
body.busy * {
cursor: wait !important;
}
The * selector with !important is essential - without it, elements that define their own cursor styles will override the busy state. This is intentionally kept minimal. The cursor itself is a browser-native UI element, and it already does exactly what you need it to do. The operating system renders it appropriately for the platform - on Windows it's a spinning circle, on Mac it's the spinning beach ball that every user immediately recognizes as "please wait."
If you want to go further with the cursor - a custom animated GIF, a branded spinner - you can replace cursor: wait with cursor: url(https://cdn.richeyweb.com/your-spinner.gif), wait. The wait fallback ensures something always displays if your custom cursor fails to load. That's a rabbit hole worth exploring on your own, but for most sites the native busy cursor is exactly right.
For mobile we'll handle things differently, which we'll get to shortly.
The JavaScript
This is where the real work happens. We've packaged everything into a class for easy reuse and clean teardown:
class BusyCursor {
constructor(options = {}) {
this.busyClass = options.busyClass ?? 'busy';
this.minDuration = options.minDuration ?? 0;
this.overlayColor = options.overlayColor ?? 'rgba(0,0,0,0.15)';
this._busyStart = null;
this._overlay = null;
this._isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
this._bound = {
onClick: this._onClick.bind(this),
onSubmit: this._onSubmit.bind(this),
onPageShow: this._onPageShow.bind(this),
onLoad: this._onLoad.bind(this),
};
}
enable() {
if (this._isTouchDevice) this._createOverlay();
document.addEventListener('click', this._bound.onClick, true);
Array.from(document.forms).forEach(form => {
form.addEventListener('submit', this._bound.onSubmit);
});
window.addEventListener('pageshow', this._bound.onPageShow);
window.addEventListener('load', this._bound.onLoad);
return this;
}
disable() {
document.removeEventListener('click', this._bound.onClick, true);
Array.from(document.forms).forEach(form => {
form.removeEventListener('submit', this._bound.onSubmit);
});
window.removeEventListener('pageshow', this._bound.onPageShow);
window.removeEventListener('load', this._bound.onLoad);
this._clearBusy();
if (this._overlay) {
this._overlay.remove();
this._overlay = null;
}
return this;
}
_createOverlay() {
this._overlay = document.createElement('div');
this._overlay.classList.add('busy-overlay');
Object.assign(this._overlay.style, {
display: 'none',
position: 'fixed',
inset: '0',
zIndex: '99999',
background: this.overlayColor,
touchAction: 'none',
});
document.body.appendChild(this._overlay);
}
_setBusy() {
this._busyStart = Date.now();
document.body.classList.add(this.busyClass);
if (this._overlay) this._overlay.style.display = 'block';
}
_clearBusy() {
const elapsed = Date.now() - (this._busyStart ?? 0);
const remaining = this.minDuration - elapsed;
const clear = () => {
document.body.classList.remove(this.busyClass);
if (this._overlay) this._overlay.style.display = 'none';
};
remaining > 0 ? setTimeout(clear, remaining) : clear();
this._busyStart = null;
}
_isNavigableLink(link) {
const href = link.getAttribute('href');
const ignoreStarts = /^(#|javascript:|mailto:|tel:)/;
if (!href || ignoreStarts.test(href)) return false;
if (link.target === '_blank') return false;
if (['modal', 'lightbox'].includes(link.rel)) return false;
if (link.dataset.bsToggle || link.dataset.toggle) return false;
if (link.dataset.bsDismiss || link.dataset.dismiss) return false;
if (link.hasAttribute('download')) return false;
if (link.getAttribute('aria-disabled') === 'true') return false;
const downloadExtensions = /\.(pdf|zip|jpg|jpeg|png|gif|webp|docx|xlsx|mp4|mov|avi|mp3)$/i;
if (downloadExtensions.test(href)) return false;
return true;
}
_onClick(e) {
const link = e.target.closest('a');
if (link) {
if (!this._isNavigableLink(link)) return;
} else {
return;
}
this._setBusy();
}
_onSubmit() {
this._setBusy();
}
_onPageShow(e) {
if (e.persisted) this._clearBusy();
}
_onLoad() {
this._clearBusy();
}
}
window.addEventListener('DOMContentLoaded', () => {
const busyCursor = new BusyCursor({ minDuration: 800 }).enable();
});
There's more going on here than it might look like at first glance. Let's walk through the decisions that aren't obvious.
Why capture: true on the click listener? Joomla and its extensions use JavaScript extensively to intercept clicks - lightboxes, modals, Bootstrap components. Many of these call stopPropagation(), which would prevent a normally-attached listener from ever seeing the event. Capture mode listens on the way down the DOM tree, before any element-level handlers fire, so we see every click regardless of what other scripts do with it afterward.
Why bind directly to forms instead of listening for submit on document? Listening for submit on document is unreliable in Joomla's environment. Binding directly to each form element is explicit and predictable. We enumerate document.forms at enable time, which covers all forms present in the DOM when the page finishes loading.
Why the _isNavigableLink exclusion list? Not every click on an anchor tag causes navigation. Bootstrap toggles, modal triggers, lightbox links, download links, mailto and tel links, and disabled links all need to be excluded. Triggering the busy state on a click that opens a modal - with no subsequent navigation to clear it - would leave the user stuck with a permanent busy cursor. The exclusion list handles all of these cases explicitly.
Why pageshow instead of just load? The load event doesn't fire when a user hits the back button and the browser serves the page from its cache (the bfcache). pageshow fires in both cases, and the e.persisted flag tells you which scenario you're in. We handle both - load for normal navigation completion and user abort, pageshow for bfcache restores.
The minDuration option exists for cases where your server responds very quickly. A busy indicator that flashes for 50 milliseconds is more jarring than no indicator at all. Setting minDuration: 800 ensures the busy state displays for at least 800ms, giving the user time to register that something happened.
Handling Feedback in Mobile
There's no cursor on a touchscreen, so the CSS approach does nothing for mobile users. But mobile users have the same problem - they tap submit and see no feedback, so they tap again.
The solution is a lightweight overlay that appears over the page content during the busy state. It serves two purposes: it gives the user visible feedback, and it physically blocks re-taps by intercepting touch events.
We detect touch devices using the pointer: coarse media query rather than user agent sniffing. This is the correct modern approach - it detects the actual input mechanism rather than trying to guess the device from a string that varies across browsers and platforms.
The overlay is only created on touch devices, so desktop users have zero overhead. On touch devices, it appears when _setBusy() fires and disappears when _clearBusy() runs.
Adding a spinner with Font Awesome:
body.busy .busy-overlay::before {
content: '\f3f4'; /* fa-circle-notch /
font-family: 'Font Awesome 6 Free';
font-weight: 900;
position: absolute;
top: 50%;
left: 50%;
/* transform: translate(-50%, -50%); only if you aren't using fa-spin */
margin-left: 1.5rem; /* half of font size - use margin with fa-spin */
margin-top: 1.5rem;
font-size: 3rem;
color: white;
animation: fa-spin 1s infinite linear;
}
fa-circle-notch is a cleaner single-arc spinner than the classic fa-spinner. The fa-spin animation is defined in Font Awesome's own stylesheet, so no additional keyframes are needed.
Implementation
Option 1: user.js and user.css
Most Joomla templates provide user.js and user.css files specifically for customizations like this. These files sit outside the template's core files and are not overwritten when you update the template. Add the JavaScript to user.js and the CSS to user.css and you're done.
If you're not sure whether your template supports these files, check your template's documentation or look for them in templates/your-template-name/css/user.css and templates/your-template-name/js/user.js. If they don't exist, you can often create them - again, check your template's documentation.
Option 2: System - Head Tag
If your template doesn't offer user files, or you want finer control over where and when the code runs - by access level, menu item, or component - the free System - Head Tag plugin for Joomla lets you inject CSS and JavaScript into the head of any page with a simple interface. It's a well-established tool in the Joomla community and a useful addition to any developer's toolkit regardless of this particular technique.
A Note on Scope
This technique handles the most common cases: standard link navigation and traditional form submissions. If your site makes heavy use of AJAX form submissions - where the page doesn't reload after a form is submitted - you'll need to tie the busy state into your AJAX callbacks directly, clearing it in the response handler rather than relying on page load events. That's a natural extension of this approach and worth a follow-up if it applies to your setup.
A small investment of code, no extensions required, and your users will always know something is happening. That's the kind of UX improvement that's easy to overlook and immediately noticeable when it's missing.