Load more button
A load-more control for the foot of a list: the closing rule fills as a progress track, with a count, one button, and pending, retry and end-of-list states. You supply the paging.
cmp_load_more_button_01 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.
Preview
Demonstration — no data is sent
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.
Using the PageSugar MCP server, fetch component cmp_load_more_button_01 version 1.0.0 with variant "neutral", 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
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-d6125735ba5689c1706fb02220e5857d9717f89e592252a065b1804f3985ca72
<!--
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, #18181b);
--_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>
Usage#
On this pageService-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 inonLoadMore. - 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
onLoadMoreshort. - 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
<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 }:addedis announced ("12 more items loaded"); whenhasMoreis false the button is replaced byendLabel.{ ok: false, message }: the message takes the count's line and the button becomes a retry. A rejected promise or an empty message showserrorMessage.
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| Name | Type | Required | Default | Description |
|---|---|---|---|---|
onLoadMore | () => Promise<{ ok: true; added: number; hasMore: boolean } | { ok: false; message: string }> | Yes | None | Fetches the next page and appends it to your list, then resolves what happened. A rejected promise or an empty message shows errorMessage. |
hasMore | boolean | Yes | None | Whether more items exist. Each result's hasMore takes over until you pass a new value; false replaces the button with endLabel. |
shown | number | No | None | Items 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. |
total | number | No | None | Total items, when known. Adds "of 48" to the count and enables the progress rule. |
label | string | No | 'Load more' | Button label at rest. Name what loads: "Show older posts". |
pendingLabel | string | No | 'Loading' | Button label while onLoadMore is pending, shown beside a spinner with a trailing ellipsis. |
retryLabel | string | No | 'Try again' | Button label after a failure. The next press retries. |
endLabel | string | No | 'You have reached the end' | Replaces the button once nothing remains. |
errorMessage | string | No | 'Those items did not load. Check your connection and try again.' | Fallback failure text when onLoadMore rejects or returns an empty message. |
focusTarget | () => HTMLElement | null | No | None | Returns 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. |
showProgress | boolean | No | false | Draw the closing rule as a progress track. Needs shown and total. |
formatCount | (shown: number, total?: number) => string | No | None | Count wording, for other languages or nouns. Defaults to "Showing 12 of 48" with the numbers emphasised. |
formatLoaded | (added: number) => string | No | (added) => `${added} more ${added === 1 ? 'item' : 'items'} loaded` | The polite announcement after a successful load. The end label is appended when the list ends. |
class | string | No | None | Extra classes on the root, for placement (margins, max width). |
Customization#
On this pageConnect 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
focusTargetreturning 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"),
endLabelsays the list is complete in your voice.formatCountandformatLoadedhandle other languages and nouns. - Accent: set
--load-more-button-accentfor the progress fill and focus ring. - Button edge:
--load-more-button-control-borderdraws 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-surfaceto a step above the page,--load-more-button-inkto near-white,--load-more-button-mutedto a grey that clears 4.5:1,--load-more-button-hairlineto rgb(255 255 255 / 0.12),--load-more-button-control-borderto a grey that clears 3:1 on that surface (#71717aon#18181b) and--load-more-button-errorto 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
| Variable | Token |
|---|---|
--load-more-button-accent | accent |
--load-more-button-ink | ink |
--load-more-button-muted | muted |
--load-more-button-hairline | hairline |
--load-more-button-surface | surface |
--load-more-button-error | error |
--load-more-button-control-border | controlBorder |
Dependencies and services#
On this pageNo 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
onLoadMoreto 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 showserrorMessage. - Keep shown (and total, when known) in step with your list.
Accessibility#
On this page- A native <button type="button">. While
onLoadMoreis pending it carriesaria-busyandaria-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
hasMoreprop alone announces nothing; the end line is visible text. focusTargetis 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
hasMoreprop, 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-hiddenbecause the count says the same thing in words. - Failure and end lines carry an
aria-hiddenmark beside the text, so colour is never the only signal. - Default contrast on white: ink
#18181babout 17:1, muted#52525babout 7.7:1, error#b91c1cabout 6.5:1. The button's edge#88888fclears 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-surfaceto 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.