Live demo

JS
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:

JS
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 HTMLElement being opened
signal
AbortSignal for cancelling the operation if the user closes the panel
options.once
true loads once and caches; false always reloads. Default: false

Using the event directly

For more control, listen to panel:beforeopen on the element:

JS
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 to fetch() so in-flight requests cancel when the user closes the panel before it opens.
waitUntil(promise)
Recommended. Delay opening until promise resolves. Safe to call more than once: the open waits for all of them.
promise
Promise | null: the underlying property waitUntil() sets. You can still assign it directly, but waitUntil() is preferred. Leave as null to 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.

JS
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.

JS
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:

JS
// fib-worker.js
onmessage = (e) => {
	const fib = (n) => (n <= 1 ? n : fib(n - 1) + fib(n - 2));
	postMessage(fib(e.data));
};

Loading states

Loading spinner

During async operations, Panel shows a spinner. It only appears if loading takes longer than loadingDelay ms. Fast loads open directly without a spinner:

JS
const panel = new Panel('#my-panel', {
	loadingDelay: 320  // Wait 320ms before showing spinner
});

Or via data attribute:

HTML
<div data-panel data-panel-loading-delay="320">...</div>

Loading height

Reserve vertical space for the spinner while content loads:

JS
const panel = new Panel('#my-panel', {
	loadingHeight: 200  // Example increase from default 150 during load
});
HTML
<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:

JS
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:

JS
panel.onBeforeOpen((el, signal) => {
	signal.addEventListener('abort', () => {
		console.log('Load cancelled');
	});

	return fetch('/api/data', { signal });
});