Skip to content

Before you use this component

  • Requires an external service: Paginated data source. The backend is not included; see responsibilities.
  • Requires client-side JavaScript to work.
Download ZIP

Blue accent palette · 8.8 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_load_more_button_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_load_more_button_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Blue accent
Version
1.0.0
Digest
Full digest
sha256-b138fe002f1ebcb13b9a42283848c15627b92f8df3f85e578348db9529fd8a26
LoadMoreButton.svelte Svelte · 11.4 KB Raw
<!--
	A load-more control for the foot of a list. The list's closing hairline doubles as its progress
	track: one full-width rule whose filled share is how much of the list is showing, with the count
	and a single wide button centred beneath it. The consumer owns the list; onLoadMore appends the
	next page to it and reports what happened, and this control shows pending, failure and the end.
-->
<script lang="ts" module>
	export type LoadMoreResult =
		{ ok: true; added: number; hasMore: boolean } | { ok: false; message: string };
</script>

<script lang="ts">
	import { tick } from 'svelte';

	interface Props {
		/** Fetch the next page, append it to your own list, then report what happened. */
		onLoadMore: () => Promise<LoadMoreResult>;
		/** Whether more items exist. A result's hasMore takes over until this prop changes. */
		hasMore: boolean;
		/** Items currently in the list. Keep it in step as you append. */
		shown?: number;
		/** Total items, when the backend knows it. */
		total?: number;
		/** Button label at rest. */
		label?: string;
		/** Button label while onLoadMore is pending. */
		pendingLabel?: string;
		/** Button label after a failure; the next press retries. */
		retryLabel?: string;
		/** Replaces the button once nothing remains. */
		endLabel?: string;
		/** Shown when onLoadMore rejects or returns an empty message. */
		errorMessage?: string;
		/** Returns the first newly added item; focus moves there after a successful load. */
		focusTarget?: () => HTMLElement | null;
		/** Draw the closing rule as a progress track from shown and total. */
		showProgress?: boolean;
		/** Count text, for other wording or languages. Defaults to "Showing 12 of 48". */
		formatCount?: (shown: number, total?: number) => string;
		/** Announcement after a load. Defaults to "12 more items loaded". */
		formatLoaded?: (added: number) => string;
		/** Extra classes for placement (margins, max width). */
		class?: string;
	}

	let {
		onLoadMore,
		hasMore,
		shown,
		total,
		label = 'Load more',
		pendingLabel = 'Loading',
		retryLabel = 'Try again',
		endLabel = 'You have reached the end',
		errorMessage = 'Those items did not load. Check your connection and try again.',
		focusTarget,
		showProgress = false,
		formatCount,
		formatLoaded = (added: number) => `${added} more ${added === 1 ? 'item' : 'items'} loaded`,
		class: className
	}: Props = $props();

	const uid = $props.id();

	/* A result's hasMore overrides the prop until the consumer passes a new value. */
	let more = $derived(hasMore);
	let pending = $state(false);
	let failure = $state<string | null>(null);
	let announcement = $state('');

	let button = $state<HTMLButtonElement | null>(null);
	let end = $state<HTMLElement | null>(null);
	/** Set when the button held focus as it was replaced, so focus can land on the end line. */
	let focusEnd = $state(false);
	/** Cleared on destroy, so a request that settles later touches neither state nor focus. */
	let alive = true;

	/* A failure belongs to the button; once the list has ended it no longer applies. */
	const error = $derived(more ? failure : null);
	/* An empty list has nothing to count: "Showing 0" is noise, so the count needs one item. */
	const hasCount = $derived(shown !== undefined && shown > 0);
	const describedBy = $derived(
		[error && `${uid}-error`, hasCount && `${uid}-count`].filter(Boolean).join(' ') || undefined
	);
	const ratio = $derived(
		shown !== undefined && total !== undefined && total > 0
			? Math.min(1, Math.max(0, shown / total))
			: null
	);
	const progressVisible = $derived(showProgress && ratio !== null);

	async function announce(message: string) {
		// Clear first, so the same message twice in a row is still announced.
		announcement = '';
		await tick();
		if (alive) announcement = message;
	}

	async function load() {
		if (pending || !more) return;
		pending = true;
		failure = null;
		let result: LoadMoreResult;
		try {
			result = await onLoadMore();
		} catch {
			result = { ok: false, message: '' };
		}
		if (!alive) return;
		pending = false;

		if (!result.ok) {
			failure = result.message || errorMessage;
			announce(failure);
			return;
		}

		more = result.hasMore;
		const message = formatLoaded(result.added) + (result.hasMore ? '' : `. ${endLabel}`);
		await tick();
		if (!alive) return;
		announce(message);

		const target = result.added > 0 ? focusTarget?.() : null;
		if (target) {
			if (target.tabIndex < 0 && !target.hasAttribute('tabindex')) {
				target.setAttribute('tabindex', '-1');
			}
			target.focus();
		}
	}

	$effect(() => () => {
		alive = false;
	});

	/* However the list ends (a result or the prop), a focused button hands focus to the end line
	   instead of letting it fall to <body>. Pre-effects run before the button leaves the DOM. */
	$effect.pre(() => {
		if (!more && button && document.activeElement === button) focusEnd = true;
	});
	$effect(() => {
		if (focusEnd && end) {
			focusEnd = false;
			end.focus();
		}
	});
</script>

<div
	class={['load-more-button flex w-full flex-col items-center text-center', className]}
	style:--_progress={ratio ?? 0}
>
	{#if progressVisible}
		<!-- The closing rule: a hairline track with the shown share filled in the accent. The count
		     below says the same thing in words, so the bar itself stays out of the accessibility tree. -->
		<div class="relative mb-6 h-[3px] w-full" aria-hidden="true">
			<span class="absolute inset-x-0 top-px h-px bg-(--_hairline)"></span>
			<span class="load-more-button__fill absolute inset-0 bg-(--_accent)"></span>
		</div>
	{/if}

	{#if hasCount}
		<p id="{uid}-count" class="mb-3 text-sm leading-5 text-pretty text-(--_muted) tabular-nums">
			{#if formatCount}
				{formatCount(shown!, total)}
			{:else if total !== undefined}
				Showing <span class="font-medium text-(--_ink)">{shown}</span> of
				<span class="font-medium text-(--_ink)">{total}</span>
			{:else}
				Showing <span class="font-medium text-(--_ink)">{shown}</span>
			{/if}
		</p>
	{/if}

	<!-- The button and the end line share one cell. An invisible copy of the button's box stays
	     mounted in it, so the cell keeps the button's size (a wrapped label included) after the
	     button itself is gone, and ending the list moves nothing. -->
	<div class="grid w-full place-items-center sm:w-auto">
		<span
			class="invisible col-start-1 row-start-1 grid min-h-10 w-full place-items-center px-5 py-2 text-sm leading-5 font-medium sm:w-auto sm:min-w-44 pointer-coarse:min-h-11"
			aria-hidden="true"
		>
			{@render labels()}
		</span>
		{#if more}
			<button
				bind:this={button}
				type="button"
				class={[
					'col-start-1 row-start-1 grid min-h-10 w-full cursor-pointer place-items-center rounded-lg px-5 py-2 text-sm leading-5 font-medium select-none sm:w-auto sm:min-w-44 pointer-coarse:min-h-11',
					'bg-(--_surface) text-(--_ink) shadow-(--_edge) hover:bg-(--_surface-hover)',
					'transition-[background-color,scale] duration-150 ease-(--_ease) active:scale-[.98] active:duration-[80ms] motion-reduce:transition-[background-color] motion-reduce:active:scale-100',
					'outline-offset-2 outline-(--_accent) focus-visible:outline-2',
					'aria-busy:cursor-progress aria-busy:hover:bg-(--_surface) aria-busy:active:scale-100'
				]}
				aria-busy={pending ? 'true' : undefined}
				aria-disabled={pending ? 'true' : undefined}
				aria-describedby={describedBy}
				onclick={load}
			>
				{@render labels()}
			</button>
		{:else}
			<p
				bind:this={end}
				tabindex="-1"
				class="col-start-1 row-start-1 flex items-start gap-2 rounded-sm text-start text-sm leading-5 text-pretty text-(--_muted) outline-offset-2 outline-(--_accent) focus-visible:outline-2"
			>
				<!-- The check says "you have seen it all"; an empty list has seen nothing, so it goes. -->
				{#if shown !== 0}
					<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
						<svg class="size-4" viewBox="0 0 16 16" fill="none">
							<path
								d="M3.25 8.5 6.5 11.5l6.25-7"
								stroke="currentColor"
								stroke-width="1.75"
								stroke-linecap="round"
								stroke-linejoin="round"
							/>
						</svg>
					</span>
				{/if}
				<span>{endLabel}</span>
			</p>
		{/if}
	</div>

	<!-- Failures sit below the button in a lane reserved for three lines on phones and two above sm:,
	     so neither the button nor
	     anything under the control moves when one appears or clears. -->
	<div
		class="mt-3 grid min-h-15 w-full place-items-start justify-center text-sm leading-5 sm:min-h-10"
	>
		{#if error}
			<p
				id="{uid}-error"
				class="flex max-w-md items-start gap-2 text-start text-balance break-words text-(--_error)"
			>
				<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
					<svg class="size-4" viewBox="0 0 16 16" fill="none">
						<circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.75" />
						<path
							d="M8 4.75v3.5M8 10.75v.5"
							stroke="currentColor"
							stroke-width="1.75"
							stroke-linecap="round"
						/>
					</svg>
				</span>
				<span>{error}</span>
			</p>
		{/if}
	</div>

	<!-- One persistent polite region for every outcome; filled only after a press. -->
	<p class="sr-only" role="status" aria-live="polite">{announcement}</p>
</div>

{#snippet labels()}
	<!-- Every label sits in one cell, so the button is as wide as its longest state and never
	     changes width; the hidden ones are visibility: hidden, which also keeps them out of
	     the accessible name. -->
	<span class={['col-start-1 row-start-1', (pending || error) && 'invisible']}>{label}</span>
	<span class={['col-start-1 row-start-1 flex items-center gap-2', !pending && 'invisible']}>
		<svg
			class="size-4 shrink-0 animate-spin text-(--_muted) motion-reduce:animate-pulse"
			viewBox="0 0 16 16"
			fill="none"
			aria-hidden="true"
		>
			<circle
				cx="8"
				cy="8"
				r="6.25"
				stroke="currentColor"
				stroke-opacity="0.25"
				stroke-width="1.75"
			/>
			<path
				d="M14.25 8A6.25 6.25 0 0 0 8 1.75"
				stroke="currentColor"
				stroke-width="1.75"
				stroke-linecap="round"
			/>
		</svg>
		{pendingLabel}…
	</span>
	<span class={['col-start-1 row-start-1', (!error || pending) && 'invisible']}>{retryLabel}</span>
{/snippet}

<style>
	/* Public tokens: set --load-more-button-* on the control or any ancestor to retone it. */
	.load-more-button {
		--_accent: var(--load-more-button-accent, #1d4ed8);
		--_ink: var(--load-more-button-ink, #18181b);
		--_muted: var(--load-more-button-muted, #52525b);
		--_hairline: var(--load-more-button-hairline, rgb(0 0 0 / 0.1));
		--_surface: var(--load-more-button-surface, #ffffff);
		--_error: var(--load-more-button-error, #b91c1c);
		/* The button's own edge clears 3:1 on white and on its hover fill; the rule stays a hairline. */
		--_control-border: var(--load-more-button-control-border, #88888f);

		/* Formulas read by utilities: hover moves the fill one step toward the ink. */
		--_surface-hover: color-mix(in oklab, var(--_surface) 95%, var(--_ink));
		--_edge: inset 0 0 0 1px var(--_control-border), 0 1px 2px rgb(0 0 0 / 0.05);
		--_ease: cubic-bezier(0.2, 0, 0, 1);
	}

	/* The fill grows from the inline start. transform-origin has no logical keyword, so the
	   direction flip lives here with the transition and its reduced-motion override. */
	.load-more-button__fill {
		transform: scaleX(var(--_progress));
		transform-origin: left center;
		transition: transform 250ms cubic-bezier(0.16, 1, 0.3, 1);
	}
	.load-more-button__fill:dir(rtl) {
		transform-origin: right center;
	}
	@media (prefers-reduced-motion: reduce) {
		.load-more-button__fill {
			transition: none;
		}
	}
</style>

Service-required: nothing loads until you implement onLoadMore. Place it after your list. Each press calls onLoadMore once (presses while it is pending are ignored), then shows the returned message as a retry, or announces how many items arrived and, when hasMore is false, replaces the button with endLabel. It never fetches, stores or renders items, and it is not infinite scroll or numbered pagination.

Suggested location
src/lib/components/load-more-button-01
Required props
onLoadMorehasMore

Limitations

  • No data fetching and no list rendering: your code appends items and keeps shown and total current.
  • No infinite scroll and no page numbers; those are different components.
  • A request that settles after the list was reset still reports to this instance. When a filter or search replaces the list, key the control on that filter ({#key filter}) so it starts fresh, and ignore stale responses in onLoadMore.
  • The page loaded so far is not written to the URL. Add a query parameter yourself if Back should restore it.
  • Failure messages appear below the button in space reserved for three lines on phones and two from sm:, so the button and the content below never move for messages of that length. A longer message grows the control downward; keep the strings returned from onLoadMore short.
  • Pressing needs JavaScript. Without it the button renders but does nothing; link to a paged URL beside it if you need a fallback.
  • No typeface is set: the component inherits the fonts of the page you place it on.

Example

Svelte
<script lang="ts">
	import LoadMoreButton, { type LoadMoreResult } from '$lib/components/load-more-button-01/LoadMoreButton.svelte';

	type Post = { id: string; title: string };
	let { data }: { data: { posts: Post[]; total: number; cursor: string | null } } = $props();

	// Writable deriveds: appended pages stay until the next navigation reloads data.
	let posts = $derived(data.posts);
	let cursor = $derived(data.cursor);
	let list = $state<HTMLOListElement>();
	let firstNew = 0;

	async function loadMore(): Promise<LoadMoreResult> {
		const response = await fetch(`/api/posts?${new URLSearchParams({ after: cursor ?? '' })}`);
		if (!response.ok) return { ok: false, message: 'Those posts did not load. Try again.' };
		const page: { posts: Post[]; cursor: string | null } = await response.json();
		firstNew = posts.length;
		posts = [...posts, ...page.posts];
		cursor = page.cursor;
		return { ok: true, added: page.posts.length, hasMore: page.cursor !== null };
	}
</script>

<ol bind:this={list}>
	{#each posts as post (post.id)}
		<li><a href="/blog/{post.id}">{post.title}</a></li>
	{/each}
</ol>

<LoadMoreButton
	onLoadMore={loadMore}
	hasMore={cursor !== null}
	shown={posts.length}
	total={data.total}
	showProgress
	label="Show older posts"
	focusTarget={() => list?.querySelectorAll<HTMLElement>('li a')[firstNew] ?? null}
/>

The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.

Load more button#

Place the control directly after the list it pages through. It fills its parent's width, so the closing rule spans the list; constrain it with the class prop if the list is narrower.

The onLoadMore contract#

onLoadMore is called once per press. Fetch the next page, append it to your own list state, and resolve one of:

  • { ok: true, added, hasMore }: added is announced ("12 more items loaded"); when hasMore is false the button is replaced by endLabel.
  • { ok: false, message }: the message takes the count's line and the button becomes a retry. A rejected promise or an empty message shows errorMessage.

Keep shown (and total, when you know it) in step with your list. The control shows what it is given; it does not count items itself.

Focus#

Without focusTarget, focus stays on the button so a keyboard user can press again. Pass focusTarget to move focus to the first new item instead; return a link inside the item when there is one. If the button is replaced by the end line while it has focus, focus moves to that line rather than falling back to the top of the page.

Other languages#

formatCount(shown, total) and formatLoaded(added) replace the English wording. Pass the button labels and endLabel as props.

Back button#

The control does not touch the URL. If returning to the list should restore the pages already loaded, store the cursor or page count in a query parameter inside onLoadMore.

Resetting the list#

When a filter or search replaces the list, wrap the control in {#key filter} so it starts fresh: its pending state, failure and end state belong to the old list. Ignore responses for an old filter inside onLoadMore as well, since the control cannot tell which list a late response belongs to.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
onLoadMore() => Promise<{ ok: true; added: number; hasMore: boolean } | { ok: false; message: string }>YesNoneFetches the next page and appends it to your list, then resolves what happened. A rejected promise or an empty message shows errorMessage.
hasMorebooleanYesNoneWhether more items exist. Each result's hasMore takes over until you pass a new value; false replaces the button with endLabel.
shownnumberNoNoneItems currently in the list, for the count. Omitted or 0, no count renders, and an end line for an empty list drops its check mark.
totalnumberNoNoneTotal items, when known. Adds "of 48" to the count and enables the progress rule.
labelstringNo'Load more'Button label at rest. Name what loads: "Show older posts".
pendingLabelstringNo'Loading'Button label while onLoadMore is pending, shown beside a spinner with a trailing ellipsis.
retryLabelstringNo'Try again'Button label after a failure. The next press retries.
endLabelstringNo'You have reached the end'Replaces the button once nothing remains.
errorMessagestringNo'Those items did not load. Check your connection and try again.'Fallback failure text when onLoadMore rejects or returns an empty message.
focusTarget() => HTMLElement | nullNoNoneReturns the first newly added item. After a successful load, focus moves there (it gets tabindex=-1 if it is not focusable). Preferable to leaving focus on a button that may vanish.
showProgressbooleanNofalseDraw the closing rule as a progress track. Needs shown and total.
formatCount(shown: number, total?: number) => stringNoNoneCount wording, for other languages or nouns. Defaults to "Showing 12 of 48" with the numbers emphasised.
formatLoaded(added: number) => stringNo(added) => `${added} more ${added === 1 ? 'item' : 'items'} loaded`The polite announcement after a successful load. The end label is appended when the list ends.
classstringNoNoneExtra classes on the root, for placement (margins, max width).

Customization#

On this page

Connect onLoadMore to your paging, set the wording through props and formatters, and retone through seven CSS variables. The accent fills the progress rule and the focus ring; the button itself is a quiet secondary so it never competes with your page's primary action.

  • Integration: append the page inside onLoadMore, then resolve { ok: true, added, hasMore }. Keep shown in step with your list's length.
  • Focus: pass focusTarget returning the first new item's link or row so keyboard users continue from the new content. Without it, focus stays on the button, and moves to the end line if the button is replaced.
  • Copy: label names what loads ("Show older releases"), endLabel says the list is complete in your voice. formatCount and formatLoaded handle other languages and nouns.
  • Accent: set --load-more-button-accent for the progress fill and focus ring.
  • Button edge: --load-more-button-control-border draws the button's one-pixel edge. Keep it at 3:1 against the page and the hover fill; the progress track uses the lighter --load-more-button-hairline.
  • Dark or tinted page: set --load-more-button-surface to a step above the page, --load-more-button-ink to near-white, --load-more-button-muted to a grey that clears 4.5:1, --load-more-button-hairline to rgb(255 255 255 / 0.12), --load-more-button-control-border to a grey that clears 3:1 on that surface (#71717a on #18181b) and --load-more-button-error to a lighter red such as #f87171.
  • Layout: the control fills its parent's width so the rule spans your list; the button hugs its label from sm: and is full width below. Constrain the root with the class prop.

Public CSS variables

VariableToken
--load-more-button-accentaccent
--load-more-button-inkink
--load-more-button-mutedmuted
--load-more-button-hairlinehairline
--load-more-button-surfacesurface
--load-more-button-errorerror
--load-more-button-control-bordercontrolBorder

Dependencies and services#

On this page

No additional runtime packages beyond Svelte and Tailwind CSS.

Requires Paginated data source

A CMS, search API or your own endpoint that returns the next page of items. This component only calls onLoadMore and shows the outcome; your code fetches the page and appends it to your list.

Not implemented by this component

  • Fetching, storing and rendering the items
  • Cursors, ordering, deduplication and rate limits
  • Keeping the loaded page in the URL so Back returns to the same place
  • Server-side access control on what each page returns

Configuration

  • Implement onLoadMore to fetch the next page (by cursor or offset), append it to your own list state, and resolve { ok: true, added, hasMore }.
  • Resolve { ok: false, message } with a short, user-facing message when the request fails; a rejected promise shows errorMessage.
  • Keep shown (and total, when known) in step with your list.

Accessibility#

On this page
  • A native <button type="button">. While onLoadMore is pending it carries aria-busy and aria-disabled, never disabled, so keyboard focus stays on it; further presses are ignored.
  • Every button label (rest, pending, retry) sits in one grid cell, so the button keeps one width; hidden labels use visibility: hidden and stay out of the accessible name.
  • The count is linked to the button with aria-describedby; after a failure the button is described by the error message as well.
  • One persistent polite live region announces each outcome of a press: the added count ("12 more items loaded", with the end label appended when that load ends the list) or the failure message. It is cleared before each message so a repeat is still announced. Ending the list through the hasMore prop alone announces nothing; the end line is visible text.
  • focusTarget is opt-in: when supplied, focus moves to the first new item after a successful load, which is preferable to leaving it on a button that may vanish. Without it, focus stays on the button.
  • When the button is replaced by the end line while it has focus, from a result or from the hasMore prop, focus moves to the end line (tabindex=-1) instead of falling to <body>. A request that settles after the control is destroyed moves no focus.
  • The progress rule is aria-hidden because the count says the same thing in words.
  • Failure and end lines carry an aria-hidden mark beside the text, so colour is never the only signal.
  • Default contrast on white: ink #18181b about 17:1, muted #52525b about 7.7:1, error #b91c1c about 6.5:1. The button's edge #88888f clears 3:1 on white (about 3.5:1) and on its hover fill. The focus ring uses the accent at a 2px offset.
  • The button is 40px tall and grows to 44px where the pointer is coarse; it spans the width below sm:.
  • IDs come from $props.id(), so several controls on one page stay independent.

Known limitations

  • The consumer is responsible for keeping shown and total accurate; the count shows what it is given.
  • Contrast is computed for the default tokens and the blue palette only; re-check any changed colour.
  • The hover shade uses color-mix(), which needs Chrome 111, Safari 16.2 or Firefox 113 and newer. Older browsers drop the hover fill, so the button shows the page behind it on hover; set --load-more-button-surface to match the page if you support them.

Release details#

On this page
Integration
  • Service required
  • Requires client-side JavaScript to be interactive
  • Server-side rendering supported
  • Connects to your own backend for Paginated data source
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 22 October 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.