Async panels
Load panel content with promises, fetch, or heavy computation
Live demo
const panel = new Panel('#async-panel-recipe');
panel.onBeforeOpen((el, signal) => {
return slowFetch('https://dummyjson.com/recipes/1', { signal })
.then(r => r.json())
.then(data => {
el.querySelector('.panel-wrapper').innerHTML = renderRecipe(data);
});
}, { once: true });
The panel below fetches a random recipe from a public API when opened. A simulated delay makes the spinner visible. With { once: true } the content is cached, so reopening the panel skips the fetch.
The onBeforeOpen() callback
Use onBeforeOpen() to add async loading:
const panel = new Panel('#my-panel');
panel.onBeforeOpen((el, signal) => {
// Return a promise to delay opening
return fetch(`/api/content`, { signal })
.then(response => response.text())
.then(html => {
el.querySelector('.panel-wrapper').innerHTML = html;
});
}, { once: true }); // Load once, cache forever
Parameters
- el
- The panel
HTMLElementbeing opened - signal
AbortSignalfor cancelling the operation if the user closes the panel- options.once
trueloads once and caches;falsealways reloads. Default:false
Using the event directly
For more control, listen to panel:beforeopen on the element:
document.querySelector('#my-panel').addEventListener('panel:beforeopen', e => {
// panel:beforeopen bubbles, so read the panel from e.target (the dispatching panel). e.currentTarget breaks under event delegation; e.target does not.
const panel = e.target;
const { signal } = e.detail;
if (panel.dataset.loaded === 'true') return; // skip if already loaded
e.detail.waitUntil(
fetch('/api/content', { signal })
.then(r => r.text())
.then(html => {
panel.querySelector('.panel-wrapper').innerHTML = html;
panel.dataset.loaded = 'true';
})
);
});
Event detail properties
- signal
AbortSignal: pass tofetch()so in-flight requests cancel when the user closes the panel before it opens.- waitUntil(promise)
- Recommended. Delay opening until
promiseresolves. Safe to call more than once: the open waits for all of them. - promise
Promise | null: the underlying propertywaitUntil()sets. You can still assign it directly, butwaitUntil()is preferred. Leave asnullto open immediately.- trigger
HTMLElement | null: the element that initiated the open (e.g. the button).
Heavy computation
Works for CPU-heavy operations too, not just network requests. Two flavours:
Simulated slow load (demo only)
This one does no real work. It only simulates latency with a timer, so the spinner is easy to see. The signal cancels the timer if you close the panel first.
panel.onBeforeOpen((el, signal) => new Promise((resolve, reject) => {
// Demo only: no real work happens here, the timer just simulates latency.
const t = setTimeout(() => {
el.querySelector('.panel-wrapper').innerHTML = '<h3>Done</h3><p>Simulated 2s load.</p>';
resolve();
}, 2000);
signal.addEventListener('abort', () => {
clearTimeout(t);
reject(new DOMException('Aborted', 'AbortError'));
});
}), { once: true });
Genuine CPU-heavy work
Real heavy work has to leave the main thread. Doing it synchronously inside the promise would still freeze the UI (and the spinner); a Web Worker is what keeps the page responsive. The signal terminates the worker on close.
panel.onBeforeOpen((el, signal) => new Promise((resolve, reject) => {
const worker = new Worker('/assets/scripts/fib-worker.js');
worker.postMessage(42);
worker.onmessage = (e) => {
el.querySelector('.panel-wrapper').innerHTML =
`<h3>Calculation complete</h3><p>fibonacci(42) = ${e.data}</p>`;
worker.terminate();
resolve();
};
signal.addEventListener('abort', () => {
worker.terminate();
reject(new DOMException('Aborted', 'AbortError'));
});
}), { once: false });
The worker file (fib-worker.js) does the calculation off the main thread:
// fib-worker.js
onmessage = (e) => {
const fib = (n) => (n <= 1 ? n : fib(n - 1) + fib(n - 2));
postMessage(fib(e.data));
};
Loading states
Loading indicator in the panel area
During async operations, Panel shows a spinner in the panel. It only appears if loading takes longer than loadingDelay ms. Fast loads open directly without a spinner. Its size, colour, speed and position are all CSS variables, and it can be switched off: see Loading indicators.
const panel = new Panel('#my-panel', {
loadingDelay: 320 // Wait 320ms before showing spinner
});
Or via data attribute:
<div data-panel data-panel-loading-delay="320">...</div>
Other ways to show it
The spinner is the default, not the only option. While content loads, the panel gets .is-loading and the button that opened it gets aria-busy="true", so the wait can be shown anywhere: a spinner in the button, a progress bar, skeleton content, a changed label. The spinner's own size, colour, speed and position are CSS variables, and it can be switched off. It is all on Loading indicators, with live examples.
Loading height
Reserve vertical space for the spinner while content loads:
const panel = new Panel('#my-panel', {
loadingHeight: 200 // Example increase from default 150 during load
});
<div data-panel data-panel-loading-height="200">...</div>
Abort handling
When the user closes the panel during loading, the operation is automatically cancelled via the AbortSignal:
panel.onBeforeOpen((el, signal) => {
// Pass signal to fetch (automatically cancels on close)
return fetch('/api/data', { signal })
.then(/* ... */);
});
If you need to detect the abort (e.g. to clean up state), check the signal directly:
panel.onBeforeOpen((el, signal) => {
signal.addEventListener('abort', () => {
console.log('Load cancelled');
});
return fetch('/api/data', { signal });
});