<x-widget.pagination>
Pagination
Paginator links in seven styles: drawer (bottom sheet of pages), numbers, segmented numbers, jump-to-page, table footer with rows per page, a summary with previous/next, and load more. In a Livewire component (WithPagination) every style changes page in place.
php artisan larawell:add pagination
Usage
Livewire component
In a Livewire component, use WithPagination: every style's page links (and the jump form and the page sheet) then call gotoPage() on the component, in place, instead of reloading the page, and the URL follows. For rows per page (type="footer") or load more, add a property and name it with per-page-model. Every public property can be set from the browser, so check the page size before it reaches the query.
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Orders extends Component
{
use WithPagination;
#[Url]
public string $search = '';
// per-page-model="perPage": the footer's select sets it; load more raises it by a page each time.
public int $perPage = 10;
public function updatedSearch(): void
{
$this->resetPage();
}
public function render()
{
return view('livewire.orders', [
'orders' => auth()->user()->orders()
->when($this->search, fn ($query) => $query->where('number', 'like', '%'.addcslashes($this->search, '%_\\').'%'))
->latest()
->paginate(min(max($this->perPage, 1), 100)),
]);
}
}
Livewire view
The view for that component. Any style works as it is; per-page-model is only needed for rows per page and load more. Give each item a wire:key.
<div>
<input type="search" wire:model.live.debounce.300ms="search" placeholder="Search orders" aria-label="Search orders">
<ul id="orders">
@foreach ($orders as $order)
<li wire:key="order-{{ $order->id }}">{{ $order->number }}</li>
@endforeach
</ul>
{{-- Pick one. --}}
<x-widget.pagination type="numbers" :paginator="$orders" />
<x-widget.pagination type="footer" :paginator="$orders" per-page-model="perPage" :per-page-options="[10, 25, 50]" />
<x-widget.pagination type="load-more" :paginator="$orders" target="#orders" per-page-model="perPage" />
</div>
Examples
Drawer
The default. Tapping the current page opens a bottom sheet listing every page, built only when it first opens. Pass any paginator, e.g. Order::query()->paginate(10) from your controller.
Pass
$orders
from your controller.
Show code Hide code
<x-widget.pagination :paginator="$orders" />
Numbers
First, last, the current page and its neighbours, with gaps shown as an ellipsis. Pass any paginator, e.g. Order::query()->paginate(10) from your controller.
Pass
$orders
from your controller.
Show code Hide code
<x-widget.pagination type="numbers" :paginator="$orders" />
Segmented
The numbers style as one joined, bordered group with the current page filled. A boxier look for admin screens. Pass any paginator, e.g. Order::query()->paginate(10) from your controller.
Pass
$orders
from your controller.
Show code Hide code
<x-widget.pagination type="segmented" :paginator="$orders" />
Jump
Previous and next, plus a field to go straight to a page. Suits very long lists. Pass any paginator, e.g. Order::query()->paginate(10) from your controller.
Pass
$orders
from your controller.
Show code Hide code
<x-widget.pagination type="jump" :paginator="$orders" />
Summary
"Showing 11 to 20 of 470 results" with labelled Previous and Next buttons. Clear at any width, and works with simplePaginate() too (the total is left out). Pass any paginator, e.g. Order::query()->paginate(10) from your controller.
Pass
$orders
from your controller.
Show code Hide code
<x-widget.pagination type="summary" :paginator="$orders" />
Load more
Appends the next page to the list in place, without leaving the page. target is the list's selector, and each item must be a direct child of it. Without JavaScript it's a link to the next page. Works with paginate(), simplePaginate() and cursorPaginate(); pass e.g. Order::query()->latest()->paginate(5) from your controller. In a Livewire component, add per-page-model="perPage": the component then renders the longer list itself (see Usage).
Pass
$orders
from your controller.
- ORD-1099 $56.00
- ORD-1098 $93.00
- ORD-1097 $130.00
- ORD-1096 $167.00
- ORD-1095 $204.00
Showing 5 of 23
Load moreShow code Hide code
<div class="w-full max-w-md">
<ul id="order-list" class="bg-surface border-line divide-line divide-y rounded-xl border">
@foreach ($orders as $order)
<li class="flex items-center justify-between px-4 py-3 text-sm">
<span class="font-medium">{{ $order['number'] }}</span>
<span class="text-foreground/75 tabular-nums">{{ $order['total'] }}</span>
</li>
@endforeach
</ul>
<x-widget.pagination type="load-more" :paginator="$orders" target="#order-list" />
</div>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.pagination>
| Prop | Default | Description |
|---|---|---|
| paginator | Required | Any Laravel paginator: paginate(), simplePaginate() or cursorPaginate() (the last two get the summary style, or load more, since they have no page count). In Livewire, from a component using WithPagination. |
| type |
'drawer'
|
drawer (a bottom sheet of pages), numbers, segmented, jump, footer (with rows per page), summary or load-more. |
| per-page-options |
[10, 25, 50, 100]
|
type="footer": the sizes to offer. Declared here so they reach it: forwarded attributes keep kebab-case names. |
| per-page-name |
'per_page'
|
type="footer": the query parameter the size submits as. |
| per-page-model |
null
|
Livewire: the rows-per-page property, e.g. "perPage". type="footer" binds its select to it; type="load-more" raises it by a page each time, so the component renders the longer list. |
<x-widget.pagination.drawer>
| Prop | Default | Description |
|---|---|---|
| paginator | Required | A length-aware paginator (paginate()). |
<x-widget.pagination.footer>
| Prop | Default | Description |
|---|---|---|
| paginator | Required | A length-aware paginator (paginate()). |
| per-page-options |
[10, 25, 50, 100]
|
The sizes to offer; the current one is always offered too. |
| per-page-name |
'per_page'
|
The query parameter the size submits as; your controller reads it (and checks it against its own list). |
| per-page-model |
null
|
Livewire: the component property for rows per page, e.g. "perPage". Changing it updates in place and goes back to page 1 (resetPage, from WithPagination), as the form does without Livewire. |
<x-widget.pagination.jump>
| Prop | Default | Description |
|---|---|---|
| paginator | Required | A length-aware paginator (paginate()). |
<x-widget.pagination.load-more>
| Prop | Default | Description |
|---|---|---|
| paginator | Required | Any Laravel paginator; with cursorPaginate() it loads just as fast on page 500 as on page 2. |
| target | Required | CSS selector for the list the items are rendered in, e.g. "#order-list". New items are appended to it. |
| label |
'Load more'
|
The button's text. |
| per-page-model |
null
|
Livewire: the rows-per-page property to raise by a page each time (e.g. "perPage"), so the component renders the longer list; appended items would be gone at its next render. Without it, the button moves to the next page. |
<x-widget.pagination.numbers>
| Prop | Default | Description |
|---|---|---|
| paginator | Required | A length-aware paginator (paginate()). |
| segmented |
false
|
The pages as one joined, bordered group with the current page filled, instead of separate links. |
<x-widget.pagination.summary>
| Prop | Default | Description |
|---|---|---|
| paginator | Required | Any Laravel paginator; a cursor paginator gets the buttons without the item numbers. |
Accessibility
All 7 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.
Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.
Source
What larawell:add pagination writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds , and the theme and base CSS.
resources/views/components/widget/pagination/drawer.blade.php Show
@props([
// A length-aware paginator (paginate()).
'paginator',
])
@php
$current = $paginator->currentPage();
$last = $paginator->lastPage();
$sheetId = app(\App\View\Widget\ElementIds::class)->claim('pages-'.$paginator->getPageName());
// Narrower buttons on phones so all five fit inside a table on a 320px screen; shrink-0 so flex never squashes them instead.
$nav = 'text-foreground bg-field flex h-10 w-9 shrink-0 items-center justify-center rounded-full transition-colors sm:w-14';
@endphp
<nav aria-label="Pagination" {{ $attributes->class(['flex items-center justify-center gap-1 px-0 py-5 sm:gap-2 sm:p-5']) }}>
@foreach ([
['url' => $paginator->url(1), 'off' => $paginator->onFirstPage(), 'icon' => 'chevrons-left', 'label' => 'First page'],
['url' => $paginator->previousPageUrl(), 'off' => $paginator->onFirstPage(), 'icon' => 'chevron-left', 'label' => 'Previous page'],
] as $link)
@if ($link['off'])
<span class="{{ $nav }} opacity-40" aria-disabled="true"><x-widget.icon :name="$link['icon']" class="size-6" /></span>
@else
<a href="{{ $link['url'] }}" aria-label="{{ $link['label'] }}" class="{{ $nav }} hover:bg-line focus-visible:ring-primary outline-none focus-visible:ring-2"><x-widget.icon :name="$link['icon']" class="size-6" /></a>
@endif
@endforeach
<button
type="button"
data-modal-open="{{ $sheetId }}"
aria-label="Page {{ $current }} of {{ $last }}, choose a page"
class="bg-field border-line-strong text-foreground hover:bg-line focus-visible:ring-primary flex h-10 min-w-12 shrink-0 items-center sm:min-w-14 justify-center rounded-full border px-3 tabular-nums transition-all outline-none focus-visible:ring-2 active:scale-95"
>{{ $current }}</button>
@foreach ([
['url' => $paginator->nextPageUrl(), 'off' => ! $paginator->hasMorePages(), 'icon' => 'chevron-right', 'label' => 'Next page'],
['url' => $paginator->url($last), 'off' => ! $paginator->hasMorePages(), 'icon' => 'chevrons-right', 'label' => 'Last page'],
] as $link)
@if ($link['off'])
<span class="{{ $nav }} opacity-40" aria-disabled="true"><x-widget.icon :name="$link['icon']" class="size-6" /></span>
@else
<a href="{{ $link['url'] }}" aria-label="{{ $link['label'] }}" class="{{ $nav }} hover:bg-line focus-visible:ring-primary outline-none focus-visible:ring-2"><x-widget.icon :name="$link['icon']" class="size-6" /></a>
@endif
@endforeach
</nav>
{{-- Bottom sheet reuses the modal behaviour (data-modal): Esc, backdrop click, focus return, scroll lock. --}}
<dialog
id="{{ $sheetId }}"
data-modal
data-close-on-backdrop
tabindex="-1"
aria-label="Choose a page"
class="fixed inset-x-0 top-auto bottom-0 m-0 mx-auto h-auto max-h-none w-full max-w-110 translate-y-full bg-transparent p-0 outline-none transition-all transition-discrete duration-300 open:translate-y-0 starting:open:translate-y-full motion-reduce:transition-none backdrop:bg-foreground/40"
>
<div class="bg-surface relative rounded-t-3xl px-6 pt-10 pb-6 shadow-2xl">
<button type="button" data-modal-close aria-label="Close" class="text-muted hover:text-foreground focus-visible:ring-primary absolute top-4 right-4 grid size-8 place-items-center rounded-full outline-none focus-visible:ring-2">
<x-widget.icon name="x" class="size-5" />
</button>
{{-- Filled in by resources/js/widget/pagination when the sheet opens: rendering every page's link here
up front cost ~400 KB of HTML per 1,000 pages on every page load, for a sheet that is rarely opened. --}}
<ul
data-page-list
data-url="{{ $paginator->url(1) }}"
data-page-name="{{ $paginator->getPageName() }}"
data-current="{{ $current }}"
data-last="{{ $last }}"
class="flex max-h-52 flex-col overflow-y-auto"
></ul>
</div>
</dialog>
resources/views/components/widget/pagination/footer.blade.php Show
@props([
// A length-aware paginator (paginate()).
'paginator',
// The sizes to offer; the current one is always offered too.
'perPageOptions' => [10, 25, 50, 100],
// The query parameter the size submits as; your controller reads it (and checks it against its own list).
'perPageName' => 'per_page',
// Livewire: the component property for rows per page, e.g. "perPage". Changing it updates in place and goes
// back to page 1 (resetPage, from WithPagination), as the form does without Livewire.
'perPageModel' => null,
])
@php
$pageName = $paginator->getPageName();
$selectId = app(\App\View\Widget\ElementIds::class)->claim('per-page-'.$pageName);
$perPage = $paginator->perPage();
// The current size is always selectable, even if the controller allowed one that isn't offered here.
$options = collect($perPageOptions)->push($perPage)->map(fn (mixed $size): int => (int) $size)->unique()->sort()->values();
// Keep the rest of the query (filters, sorting). The page is dropped: a new page size starts again at page 1.
parse_str(parse_url($paginator->url(1), PHP_URL_QUERY) ?? '', $query);
unset($query[$pageName], $query[$perPageName]);
$hidden = collect(\Illuminate\Support\Arr::dot($query))
->filter(fn (mixed $value): bool => is_scalar($value))
// Arr::dot gives "filter.status"; a form field needs "filter[status]".
->mapWithKeys(fn (mixed $value, string $key): array => [preg_replace('/\.([^.]+)/', '[$1]', $key) => $value]);
@endphp
<div {{ $attributes->class(['flex flex-wrap items-center justify-center gap-x-6 gap-y-3 px-0 py-5 text-sm sm:justify-between sm:p-5']) }}>
{{-- A GET form: resources/js/widget/pagination submits it as soon as the size changes, and hides Apply. --}}
{{-- data-per-page-model: Livewire sends the change itself, so resources/js/widget/pagination doesn't submit. --}}
<form method="GET" action="{{ $paginator->path() }}{{ $paginator->fragment() ? '#'.$paginator->fragment() : '' }}" data-per-page @if ($perPageModel) data-per-page-model @endif class="flex items-center gap-2">
@foreach ($hidden as $name => $value)
<input type="hidden" name="{{ $name }}" value="{{ $value }}">
@endforeach
<label for="{{ $selectId }}" class="text-foreground/75">Rows per page</label>
{{-- A native select: a few sizes, the phone's own picker, and it works without JavaScript. Its arrow is drawn here
(appearance-none hides the browser's), so it has room from the edge and matches the other fields. --}}
<span class="relative inline-flex">
<select
id="{{ $selectId }}"
name="{{ $perPageName }}"
@if ($perPageModel) wire:model.live="{{ $perPageModel }}" wire:change="resetPage('{{ $pageName }}')" @endif
class="bg-field border-line hover:border-primary focus:border-primary h-8 cursor-pointer appearance-none rounded border ps-2.5 pe-8 tabular-nums outline-none"
>
@foreach ($options as $size)
<option value="{{ $size }}" @selected($size === $perPage)>{{ $size }}</option>
@endforeach
</select>
<x-widget.icon name="chevron-down" class="text-foreground/60 pointer-events-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2" />
</span>
{{-- Only without JavaScript (resources/js/widget/pagination submits on change). Decided by CSS, the scripting media
feature, which the browser knows before it first paints, so it never shows and then vanishes. --}}
<x-widget.button type="submit" variant="neutral" size="sm" :submit-guard="false" data-per-page-apply class="not-noscript:hidden">Apply</x-widget.button>
</form>
<div class="flex items-center gap-4">
<p class="text-foreground/75 tabular-nums">
<span class="text-foreground font-medium">{{ number_format($paginator->firstItem()) }}–{{ number_format($paginator->lastItem()) }}</span>
of {{ number_format($paginator->total()) }}
</p>
<nav aria-label="Pagination" class="flex items-center gap-1">
<x-widget.button variant="neutral" size="sm" icon="chevron-left" label="Previous page" :href="$paginator->previousPageUrl()" :disabled="$paginator->onFirstPage()" rel="prev" />
<x-widget.button variant="neutral" size="sm" icon="chevron-right" label="Next page" :href="$paginator->nextPageUrl()" :disabled="! $paginator->hasMorePages()" rel="next" />
</nav>
</div>
</div>
resources/views/components/widget/pagination/index.blade.php Show
@props([
// Any Laravel paginator: paginate(), simplePaginate() or cursorPaginate() (the last two get the summary style, or
// load more, since they have no page count). In Livewire, from a component using WithPagination.
'paginator',
// drawer (a bottom sheet of pages), numbers, segmented, jump, footer (with rows per page), summary or load-more.
'type' => 'drawer',
// type="footer": the sizes to offer. Declared here so they reach it: forwarded attributes keep kebab-case names.
'perPageOptions' => [10, 25, 50, 100],
// type="footer": the query parameter the size submits as.
'perPageName' => 'per_page',
// Livewire: the rows-per-page property, e.g. "perPage". type="footer" binds its select to it; type="load-more" raises
// it by a page each time, so the component renders the longer list.
'perPageModel' => null,
])
{{--
Works with any Laravel paginator. summary and load-more suit ->simplePaginate() and ->cursorPaginate()
as well; the other styles need a page count, so without one (no total) they fall back to summary.
--}}
@php
// A typo fails loudly, naming the values that work, instead of quietly rendering something else.
if (! in_array($type, ['drawer', 'numbers', 'segmented', 'jump', 'footer', 'summary', 'load-more'], true)) {
throw new \InvalidArgumentException("Unknown type [{$type}] for <x-widget.pagination>. Use one of: drawer, numbers, segmented, jump, footer, summary, load-more.");
}
// Livewire's paginators use a relative path ("orders"): from /orders, their links would open /orders/orders.
if (! preg_match('#^(/|[a-z][a-z0-9+.-]*:)#i', (string) $paginator->path())) {
$paginator->withPath(url()->to((string) $paginator->path()));
}
// On every style's root: in a Livewire component, resources/js/widget/pagination turns its page links (and the jump
// form) into gotoPage() calls on the component instead of page loads, and needs to know the page parameter.
$attributes = $attributes->merge([
'data-pagination' => '',
'data-page-name' => $paginator instanceof \Illuminate\Contracts\Pagination\CursorPaginator ? $paginator->getCursorName() : $paginator->getPageName(),
]);
@endphp
@if ($paginator->hasPages())
@if ($type === 'load-more')
<x-widget.pagination.load-more :paginator="$paginator" :per-page-model="$perPageModel" {{ $attributes }} />
@elseif ($type === 'summary' || ! $paginator instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator)
<x-widget.pagination.summary :paginator="$paginator" {{ $attributes }} />
@elseif ($type === 'numbers' || $type === 'segmented')
<x-widget.pagination.numbers :paginator="$paginator" :segmented="$type === 'segmented'" {{ $attributes }} />
@elseif ($type === 'jump')
<x-widget.pagination.jump :paginator="$paginator" {{ $attributes }} />
@elseif ($type === 'footer')
<x-widget.pagination.footer :paginator="$paginator" :per-page-options="$perPageOptions" :per-page-name="$perPageName" :per-page-model="$perPageModel" {{ $attributes }} />
@else
<x-widget.pagination.drawer :paginator="$paginator" {{ $attributes }} />
@endif
@endif
resources/views/components/widget/pagination/jump.blade.php Show
@props([
// A length-aware paginator (paginate()).
'paginator',
])
@php
$pageName = $paginator->getPageName();
$inputId = app(\App\View\Widget\ElementIds::class)->claim('goto-'.$pageName);
// Carry over whatever query the paginator's own links carry (filters, appends(), withQueryString()).
parse_str(parse_url($paginator->url(1), PHP_URL_QUERY) ?? '', $query);
unset($query[$pageName]);
$hidden = collect(\Illuminate\Support\Arr::dot($query))
->filter(fn (mixed $value): bool => is_scalar($value))
// Arr::dot gives "filter.status"; a form field needs "filter[status]".
->mapWithKeys(fn (mixed $value, string $key): array => [preg_replace('/\.([^.]+)/', '[$1]', $key) => $value]);
@endphp
<div {{ $attributes->class(['flex flex-wrap items-center justify-center gap-x-4 gap-y-3 px-0 py-5 sm:justify-between sm:p-5']) }}>
{{-- A plain GET form, so it works without JS. Out-of-range input is blocked by min/max. --}}
{{-- The #fragment survives a GET submit, so the jump lands on the table like the links do. --}}
<form method="GET" action="{{ $paginator->path() }}{{ $paginator->fragment() ? '#'.$paginator->fragment() : '' }}" class="flex items-center gap-2">
@foreach ($hidden as $name => $value)
<input type="hidden" name="{{ $name }}" value="{{ $value }}">
@endforeach
<label for="{{ $inputId }}" class="sr-only">Go to page</label>
<input
type="number"
id="{{ $inputId }}"
name="{{ $pageName }}"
min="1"
max="{{ $paginator->lastPage() }}"
required
placeholder="{{ $paginator->currentPage() }}"
class="bg-field border-line hover:border-primary focus:border-primary placeholder:text-muted h-8 w-16 rounded border px-2 text-sm outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
>
<x-widget.button type="submit" size="sm" aria-label="Go to page" class="h-8 px-2!"><x-widget.icon name="arrow-right" class="size-5" /></x-widget.button>
</form>
<nav aria-label="Pagination" class="flex items-center gap-1">
<x-widget.button size="sm" :href="$paginator->previousPageUrl()" :disabled="$paginator->onFirstPage()" rel="prev" aria-label="Previous page" class="p-1!"><x-widget.icon name="chevron-left" class="size-5" /></x-widget.button>
<span class="px-1 text-sm tabular-nums" aria-current="page">{{ $paginator->currentPage() }} / {{ number_format($paginator->lastPage()) }}</span>
<x-widget.button size="sm" :href="$paginator->nextPageUrl()" :disabled="! $paginator->hasMorePages()" rel="next" aria-label="Next page" class="p-1!"><x-widget.icon name="chevron-right" class="size-5" /></x-widget.button>
</nav>
</div>
resources/views/components/widget/pagination/load-more.blade.php Show
@props([
// Any Laravel paginator; with cursorPaginate() it loads just as fast on page 500 as on page 2.
'paginator',
// CSS selector for the list the items are rendered in, e.g. "#order-list". New items are appended to it.
'target',
// The button's text.
'label' => 'Load more',
// Livewire: the rows-per-page property to raise by a page each time (e.g. "perPage"), so the component renders the
// longer list; appended items would be gone at its next render. Without it, the button moves to the next page.
'perPageModel' => null,
])
@php
// The same id on every page, so the script can find this block in the next page's HTML and swap it in.
$id = app(\App\View\Widget\ElementIds::class)->claim('load-more-'.$paginator->getPageName());
$total = $paginator instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator ? $paginator->total() : null;
// Items seen so far, counting earlier pages: loading more keeps them on screen.
$seen = method_exists($paginator, 'lastItem') ? $paginator->lastItem() : null;
@endphp
{{--
Without JS the button is a plain link to the next page. With it, resources/js/widget/pagination fetches
that page, appends the items it finds in `target` to the list here, and replaces this block with the new one.
--}}
<div id="{{ $id }}" data-load-more data-target="{{ $target }}" @if ($perPageModel) data-per-page-model="{{ $perPageModel }}" data-per-page="{{ $paginator->perPage() }}" @endif {{ $attributes->class(['flex flex-col items-center gap-3 px-0 py-5 sm:p-5']) }}>
@if ($total !== null && $seen !== null)
<p class="text-foreground/75 text-sm tabular-nums">Showing {{ number_format($seen) }} of {{ number_format($total) }}</p>
{{-- A thin bar for how far through the list you are; the text above says the same for screen readers. --}}
<div class="bg-field h-1 w-40 overflow-hidden rounded-full" aria-hidden="true">
{{-- A whole-percent class from the ranges at the end of base.css, not style="". --}}
<div class="bg-primary w-[{{ max(0, min(100, (int) round($seen / max($total, 1) * 100))) }}%] h-full rounded-full"></div>
</div>
@endif
@if ($paginator->hasMorePages())
<x-widget.button variant="neutral" :href="$paginator->nextPageUrl()" data-load-more-link rel="next" class="aria-busy:pointer-events-none aria-busy:opacity-60">{{ $label }}</x-widget.button>
@else
<p class="text-muted text-sm">That's everything.</p>
@endif
<p data-load-more-status role="status" class="text-error text-sm empty:hidden"></p>
</div>
resources/views/components/widget/pagination/numbers.blade.php Show
@props([
// A length-aware paginator (paginate()).
'paginator',
// The pages as one joined, bordered group with the current page filled, instead of separate links.
'segmented' => false,
])
@php
$current = $paginator->currentPage();
$last = $paginator->lastPage();
// Seven slots from the sm breakpoint up: first, last, the current page with its neighbours, and "…" for gaps.
$wide = match (true) {
$last <= 7 => range(1, $last),
$current <= 4 => [...range(1, 5), $last],
$current >= $last - 3 => [1, ...range($last - 4, $last)],
default => [1, $current - 1, $current, $current + 1, $last],
};
// Five on phones, where seven don't fit in 320px: first, current and last.
$narrow = match (true) {
$last <= 5 => range(1, $last),
$current <= 3 => [1, 2, 3, $last],
$current >= $last - 2 => [1, $last - 2, $last - 1, $last],
default => [1, $current, $last],
};
// One list for both, so every link is rendered once: each slot (and each "…", keyed by the page it
// follows) says which layouts show it.
// The pages in each layout that a "…" follows.
$gaps = fn (array $pages): array => array_values(array_filter($pages, fn (int $page, int $i): bool => isset($pages[$i + 1]) && $pages[$i + 1] > $page + 1, ARRAY_FILTER_USE_BOTH));
[$wideGaps, $narrowGaps] = [$gaps($wide), $gaps($narrow)];
$visibility = fn (bool $inWide, bool $inNarrow): string => match (true) {
$inWide && $inNarrow => '',
$inWide => 'max-sm:hidden',
default => 'sm:hidden',
};
$slots = [];
foreach (collect([...$wide, ...$narrow])->unique()->sort()->values() as $page) {
$slots[] = ['page' => $page, 'class' => $visibility(in_array($page, $wide, true), in_array($page, $narrow, true))];
[$inWide, $inNarrow] = [in_array($page, $wideGaps, true), in_array($page, $narrowGaps, true)];
if ($inWide || $inNarrow) {
$slots[] = ['page' => '…', 'class' => $visibility($inWide, $inNarrow)];
}
}
// segmented: the same slots as one joined, bordered group with the current page filled.
// Focus rings are inset there, since the group clips anything drawn outside it.
// Both looks use 28px cells on phones (the "…" narrower still), so all seven fit inside a table on a 320px screen.
$link = $segmented ? 'hover:bg-field focus-visible:ring-primary outline-none focus-visible:ring-2 focus-visible:ring-inset' : 'hover:bg-field focus-visible:ring-primary outline-none focus-visible:ring-2';
$arrow = $segmented
? 'text-foreground grid size-7 shrink-0 place-items-center sm:size-10'
: 'text-foreground grid size-7 shrink-0 place-items-center rounded-md sm:size-9';
$number = $segmented
? 'grid h-7 min-w-7 shrink-0 place-items-center px-1 tabular-nums sm:h-10 sm:min-w-10 sm:px-3'
: 'min-w-7 shrink-0 rounded-md px-1 py-1 text-center tabular-nums sm:min-w-8 sm:px-3';
$currentLook = $segmented ? 'bg-primary-fill text-on-primary font-semibold' : 'text-primary font-bold';
$gap = $segmented ? 'text-muted grid h-7 w-5 shrink-0 place-items-center select-none sm:h-10 sm:w-auto sm:min-w-10' : 'text-muted shrink-0 px-1 select-none sm:px-2';
// A faded cell would fade its border too, so segmented dims only the icon.
$off = $segmented ? 'text-muted' : 'opacity-30';
@endphp
<nav aria-label="Pagination" {{ $attributes->class(['flex items-center justify-center px-0 py-5 sm:p-5', 'gap-0.5 sm:gap-1' => ! $segmented]) }}>
@if ($segmented)
<div class="border-line divide-line bg-surface flex divide-x overflow-hidden rounded-xl border">
@endif
@if ($paginator->onFirstPage())
<span class="{{ $arrow }} {{ $off }}" aria-disabled="true"><x-widget.icon name="chevron-left" class="size-6" /></span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" aria-label="Previous page" class="{{ $arrow }} {{ $link }}"><x-widget.icon name="chevron-left" class="size-6" /></a>
@endif
@foreach ($slots as $slot)
@if ($slot['page'] === '…')
<span class="{{ $gap }} {{ $slot['class'] }}" aria-hidden="true">…</span>
@elseif ($slot['page'] === $current)
<span aria-current="page" class="{{ $number }} {{ $currentLook }} {{ $slot['class'] }}">{{ $slot['page'] }}</span>
@else
<a href="{{ $paginator->url($slot['page']) }}" aria-label="Page {{ $slot['page'] }}" class="{{ $number }} text-foreground {{ $link }} {{ $slot['class'] }}">{{ $slot['page'] }}</a>
@endif
@endforeach
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" aria-label="Next page" class="{{ $arrow }} {{ $link }}"><x-widget.icon name="chevron-right" class="size-6" /></a>
@else
<span class="{{ $arrow }} {{ $off }}" aria-disabled="true"><x-widget.icon name="chevron-right" class="size-6" /></span>
@endif
@if ($segmented)
</div>
@endif
</nav>
resources/views/components/widget/pagination/summary.blade.php Show
@props([
// Any Laravel paginator; a cursor paginator gets the buttons without the item numbers.
'paginator',
])
@php
// Cursor paginators have no item numbers, so they get the buttons alone.
$from = method_exists($paginator, 'firstItem') ? $paginator->firstItem() : null;
$to = method_exists($paginator, 'lastItem') ? $paginator->lastItem() : null;
$total = $paginator instanceof \Illuminate\Contracts\Pagination\LengthAwarePaginator ? $paginator->total() : null;
@endphp
<nav aria-label="Pagination" {{ $attributes->class(['flex flex-col items-center gap-3 px-0 py-5 sm:flex-row sm:justify-between sm:p-5']) }}>
@if ($from !== null)
<p class="text-foreground/75 text-sm tabular-nums">
Showing <span class="text-foreground font-medium">{{ number_format($from) }}</span>
to <span class="text-foreground font-medium">{{ number_format($to) }}</span>
@if ($total !== null) of <span class="text-foreground font-medium">{{ number_format($total) }}</span> results @endif
</p>
@endif
<div @class(['flex gap-2', 'sm:ms-auto' => $from === null])>
<x-widget.button variant="neutral" size="sm" :href="$paginator->previousPageUrl()" :disabled="$paginator->onFirstPage()" icon-start="chevron-left" rel="prev">Previous</x-widget.button>
<x-widget.button variant="neutral" size="sm" :href="$paginator->nextPageUrl()" :disabled="! $paginator->hasMorePages()" icon-end="chevron-right" rel="next">Next</x-widget.button>
</div>
</nav>
resources/js/widget/pagination/index.js Show
// Drawer: builds the page list the first time its sheet opens. The server only sends
// the URL of page 1 and the page count, so the HTML stays a few KB however many pages there are.
const LINK = 'text-foreground focus-visible:ring-primary block w-full rounded-xl p-3 text-center tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-inset';
// Up to this many pages, list them all. Beyond it, building every link froze the sheet (100,000 pages:
// ~1.9 s), so list a window around the current page plus both ends; the first/last buttons and the
// previous/next arrows (or the numbers/jump styles, better suited to huge tables) reach the rest.
const LIST_ALL_UP_TO = 2000;
const WINDOW = 100;
const ENDS = 3;
function pagesToList(current, last) {
if (last <= LIST_ALL_UP_TO) {
return Array.from({ length: last }, (_, i) => i + 1);
}
const pages = new Set();
const add = (from, to) => {
for (let page = Math.max(1, from); page <= Math.min(last, to); page++) {
pages.add(page);
}
};
add(1, ENDS);
add(current - WINDOW, current + WINDOW);
add(last - ENDS + 1, last);
return [...pages].sort((a, b) => a - b);
}
function build(list) {
const { pageName, url } = list.dataset;
const current = Number(list.dataset.current);
const last = Number(list.dataset.last);
// Page 1's URL already carries the other query params and the #fragment; only the page changes.
const base = new URL(url, window.location.href);
const items = document.createDocumentFragment();
let previous = 0;
for (const page of pagesToList(current, last)) {
if (page > previous + 1) {
// A gap in the windowed list; purely visual, the numbers around it say which pages are skipped.
const gap = Object.assign(document.createElement('li'), { textContent: '…', className: 'text-muted p-1 text-center select-none' });
gap.setAttribute('aria-hidden', 'true');
items.append(gap);
}
previous = page;
const href = new URL(base);
href.searchParams.set(pageName, String(page));
const link = document.createElement('a');
link.href = href.href;
link.textContent = String(page);
link.className = page === current ? `${LINK} bg-field font-semibold` : `${LINK} hover:bg-field/60`;
if (page === current) {
link.setAttribute('aria-current', 'page');
// Picked up by the modal script: focuses it and scrolls it into view.
link.dataset.autofocus = '';
}
const item = document.createElement('li');
item.append(link);
items.append(item);
}
list.replaceChildren(items);
list.dataset.built = '';
}
// `modal:before-open` doesn't bubble, so listen in the capture phase.
document.addEventListener('modal:before-open', (event) => {
const list = event.target.querySelector?.('[data-page-list]:not([data-built])');
if (list) {
build(list);
}
}, true);
// --- Table footer: rows per page ------------------------------------------------------------------
// Submits as soon as a size is picked; the Apply button is only there for when JavaScript isn't (see footer.blade.php).
document.addEventListener('change', (event) => {
const form = event.target.closest?.('form[data-per-page]:not([data-per-page-model])');
if (form) {
form.requestSubmit();
}
});
// --- In a Livewire component ---------------------------------------------------------------------------
// There the component owns the page (WithPagination). A page link would reload everything, and the component would
// start over; instead the links, the page sheet and the jump form call gotoPage(). Not in a table, which does the
// same for its own pagination (resources/js/widget/table).
const wireFor = (element) => {
const host = element.closest('[wire\\:id]');
return host ? window.Livewire?.find(host.getAttribute('wire:id')) : null;
};
const paginated = (wire) => wire?.$get('paginators') !== undefined;
// One polite live region, as the table has: a region inside the pagination would be replaced along with it.
function announce(message) {
let region = document.getElementById('pagination-announcer');
if (!region) {
region = Object.assign(document.createElement('p'), { id: 'pagination-announcer', className: 'sr-only' });
region.setAttribute('role', 'status');
document.body.append(region);
}
region.textContent = '';
setTimeout(() => {
region.textContent = message;
}, 50);
}
async function gotoPage(wire, root, page, pageName) {
root.querySelectorAll('dialog[open]').forEach((dialog) => dialog.close());
document.querySelectorAll(`dialog[open] [data-page-list][data-page-name="${CSS.escape(pageName)}"]`).forEach((list) => list.closest('dialog').close());
await wire.$call('gotoPage', page, pageName);
// The link that was clicked may be gone (the new page is plain text); land on the pagination instead.
const fresh = root.isConnected ? root : null;
if (fresh) {
fresh.tabIndex = -1;
fresh.focus({ preventScroll: true });
}
announce(page ? `Page ${page} loaded` : 'Page loaded');
}
const inTable = (element) => element.closest('[data-table-root]') !== null;
document.addEventListener('click', (event) => {
const link = event.target.closest?.('a[href]');
if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
// The page sheet lives in a <dialog> beside the pagination; its list names the page parameter itself.
const list = link.closest('[data-page-list]');
const root = link.closest('[data-pagination]') ?? list?.closest('dialog')?.previousElementSibling?.closest('[data-pagination]');
const pageName = root?.dataset.pageName ?? list?.dataset.pageName;
if (!root || !pageName || inTable(link) || link.matches('[data-load-more-link]')) {
return;
}
const wire = wireFor(link);
const target = new URL(link.href, window.location.href);
if (!paginated(wire) || target.origin !== window.location.origin) {
return;
}
event.preventDefault();
gotoPage(wire, root, target.searchParams.get(pageName) ?? '1', pageName);
});
// The jump form ("go to page"). The rows-per-page form is left alone: its select binds with per-page-model.
document.addEventListener('submit', (event) => {
const form = event.target;
const root = form.closest?.('[data-pagination]');
if (!root || form.matches('[data-per-page]') || inTable(form)) {
return;
}
const wire = wireFor(form);
const page = new FormData(form).get(root.dataset.pageName);
if (!paginated(wire) || !page) {
return;
}
event.preventDefault();
gotoPage(wire, root, String(page), root.dataset.pageName);
});
// --- Load more ------------------------------------------------------------------------------------
// Each load adds the page size the list started with: the paginator's own grows with every load.
const loadSteps = new Map();
async function moreFromLivewire(wire, block, list, url) {
const { perPageModel, perPage, pageName } = block.dataset;
const before = list ? list.children.length : 0;
if (perPageModel && wire.$get(perPageModel) !== undefined) {
const key = `${wire.$id}:${perPageModel}`;
if (!loadSteps.has(key)) {
loadSteps.set(key, Number(perPage));
}
await wire.$set(perPageModel, Number(wire.$get(perPageModel)) + loadSteps.get(key));
} else if (paginated(wire)) {
await wire.$call('gotoPage', url.searchParams.get(pageName) ?? '2', pageName);
} else {
window.location.assign(url.href);
return;
}
// As without Livewire: focus goes to the first new item, where the list grew.
const first = list?.isConnected && perPageModel ? list.children[before] : null;
if (first) {
first.tabIndex = -1;
first.focus({ preventScroll: true });
}
}
// Fetches the next page, appends the items found in the block's target to the list on this page and
// swaps in the next page's block (new link, new count). Any failure leaves the link working as a link.
document.addEventListener('click', async (event) => {
const link = event.target.closest?.('[data-load-more] a[data-load-more-link]');
if (!link || link.hasAttribute('aria-busy') || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const block = link.closest('[data-load-more]');
const list = document.querySelector(block.dataset.target);
const url = new URL(link.href, window.location.href);
// In Livewire, items appended here would be gone at the component's next render: it must render them itself. With
// per-page-model it gets one page more; without, it moves to the next page.
const wire = wireFor(block);
if (wire) {
event.preventDefault();
await moreFromLivewire(wire, block, list, url);
return;
}
// Only same-origin HTML is parsed and moved into the page.
if (!list || url.origin !== window.location.origin) {
return;
}
event.preventDefault();
const status = block.querySelector('[data-load-more-status]');
link.setAttribute('aria-busy', 'true');
status.textContent = '';
try {
const response = await fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const next = new DOMParser().parseFromString(await response.text(), 'text/html');
const items = [...(next.querySelector(block.dataset.target)?.children ?? [])];
const nextBlock = next.getElementById(block.id);
list.append(...items.map((item) => document.importNode(item, true)));
const added = items.length ? [...list.children].slice(-items.length) : [];
if (nextBlock) {
block.replaceWith(document.importNode(nextBlock, true));
} else {
block.remove();
}
// Move focus to the first new item, so keyboard and screen reader users carry on where the list grew.
if (added[0]) {
added[0].tabIndex = -1;
added[0].focus({ preventScroll: true });
}
} catch {
link.removeAttribute('aria-busy');
status.textContent = "Couldn't load more. Try again.";
}
});
app/View/Widget/ElementIds.php Show
<?php
declare(strict_types=1);
namespace App\View\Widget;
use Illuminate\Container\Attributes\Scoped;
use LogicException;
/**
* Keeps element ids unique within one response, so labels, aria-describedby and #fragments
* always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
*/
#[Scoped]
final class ElementIds
{
/** @var array<string, true> */
private array $used = [];
/**
* Reserves an id for this response.
*
* A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
* id is one the caller chose and may reference from JS or CSS, so silently renaming it would
* break that reference; a duplicate throws instead, which surfaces in development and tests.
*/
public function claim(string $id, bool $explicit = false): string
{
if (!isset($this->used[$id])) {
return $this->reserve($id);
}
if ($explicit) {
throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
}
$suffix = 2;
while (isset($this->used["{$id}-{$suffix}"])) {
$suffix++;
}
return $this->reserve("{$id}-{$suffix}");
}
private function reserve(string $id): string
{
$this->used[$id] = true;
return $id;
}
}