Load More List
A list that loads the next page with pulsing placeholder rows, then fades real rows in; can auto-load near the end.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/load-more-list.json'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import './load-more-list.css'
export interface ListRow { id: string; title: string; meta: string }
export interface LoadMoreListProps {
loadPage: (page: number) => Promise<ListRow[]>
initial?: ListRow[]
auto?: boolean
pageSize?: number
title?: string
fill?: 'dark' | 'light'
className?: string
}
/** Paged list with placeholder rows and optional auto-load. */
export function LoadMoreList({ loadPage, initial = [], auto = false, pageSize = 4, title = 'Comments', fill = 'dark', className }: LoadMoreListProps) {
const [rows, setRows] = useState(initial)
const [page, setPage] = useState(1)
const [busy, setBusy] = useState(false)
const [end, setEnd] = useState(false)
const [msg, setMsg] = useState('')
const sentinel = useRef<HTMLDivElement>(null)
const scroller = useRef<HTMLDivElement>(null)
const more = useCallback(async () => {
if (busy || end) return
setBusy(true)
const next = await loadPage(page)
setBusy(false)
if (!next.length) { setEnd(true); setMsg('End of list'); return }
setRows((r) => [...r, ...next]); setPage((p) => p + 1); setMsg(`${next.length} more loaded`)
}, [busy, end, loadPage, page])
useEffect(() => {
if (!auto || !sentinel.current) return
const io = new IntersectionObserver((e) => { if (e[0].isIntersecting) more() }, { root: scroller.current, rootMargin: '80px' })
io.observe(sentinel.current)
return () => io.disconnect()
}, [auto, more])
return (
<div className={['sd-lml', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-lml__top">{title}</div>
<div ref={scroller} className="sd-lml__scroll">
<ul className="sd-lml__list" aria-busy={busy}>
{rows.map((r, i) => (
<li key={r.id} className="sd-lml__row" style={{ animationDelay: `${(i % pageSize) * 60}ms` }}>
<span className="sd-lml__av" aria-hidden>{r.title[0]}</span>
<span><span className="sd-lml__t" style={{ display: 'block' }}>{r.title}</span><span className="sd-lml__m">{r.meta}</span></span>
</li>
))}
</ul>
{busy && Array.from({ length: pageSize }, (_, i) => (
<div key={i} className="sd-lml__sk" aria-hidden style={{ animationDelay: `${i * 120}ms` }}><i style={{ width: 32, height: 32, borderRadius: 99 }} /><span style={{ flex: 1, display: 'grid', gap: 6 }}><i style={{ width: '60%', height: 10 }} /><i style={{ width: '35%', height: 8 }} /></span></div>
))}
<div ref={sentinel} className="sd-lml__foot">
{end ? <span className="sd-lml__end">You’re all caught up</span> : !busy && <button type="button" className="sd-lml__btn" onClick={more}>Load more</button>}
</div>
</div>
<span className="sr" aria-live="polite">{msg}</span>
</div>
)
}.sd-lml { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-lml[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-lml button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-lml :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-lml .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-lml { width: min(100%, 320px); height: 400px; display: flex; flex-direction: column; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-lml__top { padding: 12px 14px; border-bottom: 1px solid var(--line); font-size: 14px; font-weight: 650; }
.sd-lml__scroll { flex: 1; overflow: auto; }
.sd-lml__list { list-style: none; margin: 0; padding: 0; }
.sd-lml__row { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--line); animation: sd-lml-in 320ms ease both; }
@keyframes sd-lml-in { from { opacity: 0; transform: translateY(6px); } }
.sd-lml__av { flex: none; width: 32px; height: 32px; border-radius: 99px; background: var(--soft); display: grid; place-items: center; font-size: 12px; font-weight: 650; }
.sd-lml__t { font-size: 13.5px; font-weight: 550; }
.sd-lml__m { font-size: 12px; color: var(--mute); }
.sd-lml__sk { display: flex; align-items: center; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--line); animation: sd-lml-pulse 1.1s ease-in-out infinite; }
.sd-lml__sk i { display: block; border-radius: 6px; background: var(--soft); }
@keyframes sd-lml-pulse { 50% { opacity: 0.45; } }
.sd-lml__foot { padding: 12px; text-align: center; }
.sd-lml__btn { height: 34px; padding: 0 16px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--line); font-size: 13px; font-weight: 600; }
.sd-lml__btn:hover { background: var(--soft) !important; }
.sd-lml__end { font-size: 12.5px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-lml *, .sd-lml *::before, .sd-lml *::after { animation: none !important; transition: none !important; } }Overview
Load more shows placeholder rows the size of real ones (a gentle opacity pulse, no shimmer), then swaps in the results with a stagger. With auto-load on, an IntersectionObserver triggers it before you reach the bottom.
At the end it says so instead of showing a dead button.
When to use
- Feeds, comments, search results, activity logs.
When not to use
- Content people need to reach the footer of: avoid auto-load.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/load-more-list.json$
npx shadcn@latest add https://sundaeui.dev/r/load-more-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/load-more-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/load-more-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy load-more-list.tsx and load-more-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { LoadMoreList } from '@/components/sundae/load-more-list'
<LoadMoreList />Props
| Prop | Type | Default |
|---|---|---|
loadPage*Fetch a page; return [] when done. | (page: number) => Promise<T[]> | — |
autoLoad when the end scrolls into view. | boolean | false |
pageSizePlaceholder rows per page. | number | 4 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The list has aria-busy while loading; a polite region announces “4 more loaded” or “End of list”.
- The Load more button stays available even with auto-load.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-lml; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Load More List free?
- Yes. Load More List is free for personal and commercial projects, with full source on this page.
- How do I install Load More List?
- Run npx shadcn@latest add https://sundaeui.dev/r/load-more-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Load More List support keyboard and screen readers?
- The list has aria-busy while loading; a polite region announces “4 more loaded” or “End of list”. The Load more button stays available even with auto-load.
- Does Load More List respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Pull to Refresh
Components · Refined- Pro
Swipe Actions
Components · Refined - Pro
Sortable List
Components · Refined - Pro
Bulk Actions Bar
Components · Refined Grid / List View Switcher
Components · RefinedTask List
Components · Refined