Numbered Pagination
A numbered page strip that collapses long runs to an ellipsis.
6 個のフレームワーク中級
An IntersectionObserver sentinel that keeps a real Load more button beside it.
<!--
Infinite scroll, built so it is still operable without scrolling.
Two things make that true. First, the Load more button is REAL and always
present - the sentinel only clicks it early. Keyboard users never scroll a
viewport in the way this pattern assumes; strip the button out and the feed
becomes reachable by mouse wheel and nothing else. Second, the aria-live
region names the count after every append, because rows arriving unprompted
are otherwise completely silent.
rootMargin pre-fetches one viewport ahead so the content is usually there
before the user reaches the bottom. Widen it to prefetch sooner, at the cost
of loading pages nobody looks at.
-->
<div class="infeed">
<ul class="infeed__list" id="infeed-list">
<li class="infeed__item"><span class="infeed__title">Quarterly revenue report</span><span class="infeed__meta">Finance</span></li>
<li class="infeed__item"><span class="infeed__title">Onboarding checklist</span><span class="infeed__meta">People</span></li>
<li class="infeed__item"><span class="infeed__title">Incident post-mortem</span><span class="infeed__meta">Engineering</span></li>
<li class="infeed__item"><span class="infeed__title">Brand guidelines</span><span class="infeed__meta">Design</span></li>
</ul>
<div class="infeed__sentinel" id="infeed-sentinel" aria-hidden="true"></div>
<p class="infeed__status" id="infeed-status" role="status" aria-live="polite">Showing 4 of 16 results</p>
<nav aria-label="Pagination">
<button class="infeed__more" id="infeed-more" type="button">Load more</button>
</nav>
</div>
<script>
(function () {
var PAGE_SIZE = 4;
var TOTAL = 16;
var list = document.getElementById('infeed-list');
var status = document.getElementById('infeed-status');
var button = document.getElementById('infeed-more');
var sentinel = document.getElementById('infeed-sentinel');
var REMAINING = [
{ title: 'Security review', meta: 'Engineering' },
{ title: 'Pricing experiment', meta: 'Growth' },
{ title: 'Support playbook', meta: 'Success' },
{ title: 'Hiring plan', meta: 'People' },
{ title: 'Roadmap Q3', meta: 'Product' },
{ title: 'Vendor audit', meta: 'Finance' },
{ title: 'Accessibility audit', meta: 'Design' },
{ title: 'Data retention policy', meta: 'Legal' },
{ title: 'Latency budget', meta: 'Engineering' },
{ title: 'Partner agreement', meta: 'Legal' },
{ title: 'Churn analysis', meta: 'Growth' },
{ title: 'Design tokens v2', meta: 'Design' }
];
var shown = PAGE_SIZE;
var busy = false;
function loadMore() {
if (busy || shown >= TOTAL) return;
busy = true;
button.disabled = true;
button.setAttribute('aria-busy', 'true');
button.textContent = 'Loading…';
window.setTimeout(function () {
var next = REMAINING.splice(0, PAGE_SIZE);
next.forEach(function (row) {
var li = document.createElement('li');
li.className = 'infeed__item';
var title = document.createElement('span');
title.className = 'infeed__title';
title.textContent = row.title;
var meta = document.createElement('span');
meta.className = 'infeed__meta';
meta.textContent = row.meta;
li.appendChild(title);
li.appendChild(meta);
list.appendChild(li);
});
shown += next.length;
status.textContent = 'Showing ' + shown + ' of ' + TOTAL + ' results';
busy = false;
button.removeAttribute('aria-busy');
if (shown >= TOTAL) {
button.textContent = 'All results loaded';
button.setAttribute('aria-disabled', 'true');
observer.disconnect();
return;
}
button.disabled = false;
button.textContent = 'Load more';
}, 600);
}
// The observer is a convenience layer over the button, not a replacement
// for it - both paths call the same loadMore().
var observer = new IntersectionObserver(
function (entries) {
if (entries[0] && entries[0].isIntersecting) loadMore();
},
{ rootMargin: '200px 0px' }
);
observer.observe(sentinel);
button.addEventListener('click', loadMore);
})();
</script>| Prop | 型 | デフォルト | 説明 |
|---|---|---|---|
items必須 | Result[] | - | レンダリングする項目の配列。 |
total必須 | number | - | 全ページを通じた項目の総数。 |
loading | boolean | false | true の間はスピナーを表示し、入力をブロックします。 |
onLoadMore | () => void | - | 次のバッチが要求されたときに呼ばれます。 |
ariaLabel | string | 'Pagination' | ボタンのアクセシブルな名前。アイコンのみのボタンでは必須です。 |
className | string | - | ルート要素にマージされる追加クラス。 |
Infinite scroll is notoriously inaccessible, and it is worth being precise about why: it assumes scrolling a viewport is how people move through a list. Keyboard users move by focus, screen-reader users move by virtual cursor, and neither reliably trips a sentinel - so a feed with nothing but an observer is operable by mouse wheel and nothing else. This component treats the sentinel as a shortcut over a real Load more button rather than a replacement for it: both paths call the same `loadMore`, the button is always present and focusable, and the polite live region names the count after every append so rows arriving unprompted are not silent. Keep the button. It is the accessible escape hatch and the whole reason this version is shippable. The sentinel needs a box to intersect - `h-px`, never `display: none` - and `rootMargin` sets how far ahead the next page is fetched: widen it to prefetch sooner, at the cost of loading pages nobody scrolls to. Pass `root` when the feed scrolls inside a panel rather than the page, or the sentinel is measured against the viewport and either never fires or fires immediately.