Visualization

Spinner

Size sets the SVG box dimensions, with one SVG unit of padding on each side of its 64-unit view box. Strength is a fraction of the ring's outer radius (default: 0.2, or 20%) and grows inward, keeping the stroke inside the padding. A strength of 0 hides the stroke; 1 reaches the center. The playground displays progress and strength as percentages.

For hover, active and focus-visible states to work properly, you need to define the 'icon-root' class on the parent element (e.g. button). For custom active state, you can also use the 'active' class.

%
em
%

/lib/some-action.svelte

<script lang="ts">
	import { Field, NumberInput, Spinner, Toggle } from '@leon8ix/jhana-ui';

	let specific = $state(true);
	let progress = $state<number | null>(67);
	let size = $state<number | null>(2);
	let strength = $state<number | null>(20);
</script>

<div class="spinner-demo">
	<div class="fields">
		<Field label="State">
			{#snippet children(control)}
				<Toggle {...control} boxed bind:checked={specific} label={{ off: 'Undefined', on: 'Specified' }} />
			{/snippet}
		</Field>
		<NumberInput label="Progress" unit="%" bind:value={progress} min={0} max={100} step={5} disabled={!specific} />
		<NumberInput label="Size" unit="em" bind:value={size} min={0.25} max={16} step={0.25} />
		<NumberInput label="Strength" unit="%" bind:value={strength} min={0} max={100} step={1} />
		<div class="field-accessory spinner-preview">
			<Spinner
				value={specific ? (progress ?? 0) / 100 : undefined}
				size={`${size ?? 2}em`}
				strength={(strength ?? 20) / 100}
			/>
		</div>
	</div>
</div>

<style>
	.spinner-demo {
		container-type: inline-size;
	}
	.fields {
		grid-template-columns: max-content repeat(3, minmax(0, 8em)) max-content;
		justify-content: start;
	}
	@container (max-width: 48em) {
		.fields {
			grid-template-columns: repeat(auto-fit, minmax(min(100%, 8em), max-content));
		}
		.spinner-preview {
			grid-column: 1 / -1;
		}
	}
</style>

Presets

Without a preset, the spinner uses 2em and 20% strength. Use preset="button" for 1.4em and 25%, or preset="inline" for 1em and 30%. Explicit size and strength values override the preset independently. Colors and progress work the same for every preset.

Default: 2em / 20%
Button: 1.4em / 25%
Inline: 1em / 30%
Uploading

/lib/spinner-presets.svelte

<script lang="ts">
	import { Spinner } from '@leon8ix/jhana-ui';
</script>

<div class="presets" role="group" aria-label="Spinner presets">
	<div>
		<span>Default: 2em / 20%</span>
		<Spinner />
	</div>
	<div>
		<span>Button: 1.4em / 25%</span>
		<button type="button" class="btn" disabled>
			<span>Saving</span>
			<span><Spinner preset="button" /></span>
		</button>
	</div>
	<div>
		<span>Inline: 1em / 30%</span>
		<div class="status"><Spinner preset="inline" /> Uploading</div>
	</div>
</div>

<style>
	.presets {
		display: flex;
		flex-wrap: wrap;
		gap: 2em;

		& > div {
			display: flex;
			flex-direction: column;
			align-items: flex-start;
			gap: 0.75em;
		}
	}

	.status {
		display: flex;
		align-items: center;
		gap: 0.5em;
	}
</style>

Progress Bar

In case you want to visualize the progress of a major action, the Spinner might be too subdued. In those cases, you should use this beautiful ProgressBar component.

/routes/+layout.svelte

<script lang="ts">
	import { NumberInput, ProgressBar } from '@leon8ix/jhana-ui';

	let value = $state(0.5);
</script>

<div class="input-group">
	<NumberInput bind:value min={0} max={1} step={0.05} />
	<ProgressBar {value} width="50%" />
</div>

Page Progress

Mount one PageProgressRoot in the app layout, then import pageProgress from @leon8ix/jhana-ui/page-progress in any component or client-side function. Assign a fraction to pageProgress.value to update the shared indicator. Its manual popover keeps it visible over dialogs and popovers, alongside notifications, without blocking clicks or taking focus. Set its color and strength on the root; the defaults are var(--cAcc) and 2px.

/routes/+layout.svelte

<script lang="ts">
    import { PageProgressRoot } from '@leon8ix/jhana-ui/page-progress';
</script>

<PageProgressRoot />

/lib/loading.ts

import { pageProgress } from '@leon8ix/jhana-ui/page-progress';

// Call from client-side work; null finishes the indicator.
export function reportProgress(value: number | null) {
    pageProgress.value = value;
}

Displays page-loading progress along the top edge of the viewport, with a glow that grows stronger toward the leading edge. JavaScript smooths both frequent updates and large progress steps. Set pageProgress.value to 0, null or undefined when loading finishes. Clearing the value starts a 1700 ms fade of the highlight and glow toward 20%, regardless of the last progress value. Keeping a value of 1 retains the full glow. After a 1000 ms pause, the trail follows the same spring motion from left to right through a soft gradient. The wipe catches the highlighted tip and its glow shortly after the front reaches its last target. No extra wait is needed before clearing the value.

/routes/PageProgressDemo.svelte

<script lang="ts">
	import { NumberInput, Toggle } from '@leon8ix/jhana-ui';
	import { pageProgress } from '@leon8ix/jhana-ui/page-progress';
	import { onMount } from 'svelte';
	import { createPageProgressSimulation } from './page-progress-simulation.svelte';

	const { progress, simulateLoading } = createPageProgressSimulation();
	$effect(() => {
		pageProgress.value = progress.show ? progress.value : null;
	});
	onMount(() => () => {
		pageProgress.value = null;
	});
</script>

<div class="input-group">
	<Toggle boxed bind:checked={progress.show} label="Show progress" disabled={progress.simulating} />
	<NumberInput
		aria-label="Page progress"
		bind:value={progress.value}
		min={0}
		max={1}
		step={0.05}
		disabled={!progress.show || progress.simulating}
	/>
	<button type="button" class="btn" onclick={() => simulateLoading(false)} disabled={progress.simulating}
		>Simulate loading</button
	>
	<button type="button" class="btn" onclick={() => simulateLoading(true)} disabled={progress.simulating}
		>Simulate instant loading</button
	>
</div>

/routes/page-progress-simulation.svelte.ts

import { tick } from 'svelte';

export function createPageProgressSimulation() {
const progress = $state({ show: false, value: 0.5, simulating: false });
let instant = $state(false);

function simulateLoading(instantLoading: boolean) {
	instant = instantLoading;
	progress.value = instant ? 1 : 0.05;
	progress.show = true;
	progress.simulating = true;
}

$effect(() => {
	if (!progress.simulating) return;

	// Instant loading skips straight to the completion and exit phase.
	const start = performance.now() - (instant ? 2700 : 0);
	const timer = setInterval(async () => {
		const elapsed = performance.now() - start;
		let value = progress.value;
		if (elapsed < 900) value = 0.05 + (elapsed / 900) * 0.4;
		else if (elapsed < 1150) value = 0.45;
		else if (elapsed < 1950) value = 0.45 + ((elapsed - 1150) / 800) * 0.35;
		else if (elapsed < 2200) value = 0.8;
		else if (elapsed < 2700) value = 0.8 + ((elapsed - 2200) / 500) * 0.2;
		else {
			clearInterval(timer);
			progress.value = 1;
			// Deliver the final target before clearing it; PageProgress handles the animation.
			await tick();
			progress.show = false;
			progress.simulating = false;
			return;
		}
		progress.value = Math.round(value * 100) / 100;
	}, 50);

	return () => clearInterval(timer);
});

return { progress, simulateLoading };
}

This is one shared progress channel: the app decides which operation owns it. Update it only in browser-side code, and clear it when the owning operation finishes or is cancelled. If you need a locally controlled renderer, PageProgress from the main package still accepts value, color and strength; that component does not manage popup layers.