A square-cornered pagination bar for the bottom of a table or list. On the left it has a rows per page select and a line such as 41-50 of 248 orders. On the right it has Previous and Next buttons with numbered pages between them, shortened with an ellipsis when there are many. Everything works: changing the page size recalculates the page count and keeps you near the same rows. On phones the numbers are replaced by a short Page 5 of 25 label between the two buttons.
import { useState } from "react";
const totalItems = 248;
const pageSizes = [10, 25, 50, 100];
function pageList(current: number, last: number): (number | "gap")[] {
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1);
if (current <= 4) return [1, 2, 3, 4, 5, "gap", last];
if (current >= last - 3) return [1, "gap", last - 4, last - 3, last - 2, last - 1, last];
return [1, "gap", current - 1, current, current + 1, "gap", last];
}
export default function PaginationWithPageSizeSelect() {
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(5);
const lastPage = Math.ceil(totalItems / pageSize);
const first = (page - 1) * pageSize + 1;
const last = Math.min(page * pageSize, totalItems);
const changeSize = (size: number) => {
setPageSize(size);
setPage(Math.min(Math.ceil(first / size), Math.ceil(totalItems / size)));
};
const step = "inline-flex h-10 items-center gap-1.5 border border-slate-300 bg-white px-3.5 text-sm font-semibold text-slate-900 transition hover:border-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:border-slate-200";
return (
<div className="bg-white px-4 py-10 text-slate-900 sm:px-8 sm:py-14">
<nav aria-label="Pagination" className="mx-auto flex max-w-4xl flex-col gap-4 border-t-2 border-slate-900 pt-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-center justify-between gap-4 lg:justify-start lg:gap-6">
<div className="flex items-center gap-2.5">
<label htmlFor="page-size" className="text-sm text-slate-600">Rows per page</label>
<div className="relative">
<select
id="page-size"
value={pageSize}
onChange={(event) => changeSize(Number(event.target.value))}
className="h-10 appearance-none border border-slate-300 bg-white pl-3 pr-9 text-sm font-semibold tabular-nums text-slate-900 transition hover:border-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2"
>
{pageSizes.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="pointer-events-none absolute right-2.5 top-3 h-4 w-4 text-slate-600"><path d="m6 8 4 4 4-4" /></svg>
</div>
</div>
<p aria-live="polite" className="text-sm tabular-nums text-slate-600">
<span className="font-semibold text-slate-900">{first}-{last}</span> of {totalItems} orders
</p>
</div>
<div className="flex items-center justify-between gap-2">
<button type="button" onClick={() => setPage(page - 1)} disabled={page === 1} className={step}>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4"><path d="M12 5 7 10l5 5" /></svg>
Previous
</button>
<p className="text-sm tabular-nums text-slate-600 sm:hidden">Page <span className="font-semibold text-slate-900">{page}</span> of {lastPage}</p>
<ul className="hidden items-center gap-1 sm:flex">
{pageList(page, lastPage).map((item, index) =>
item === "gap" ? (
<li key={`gap-${index}`} aria-hidden="true" className="flex h-10 w-8 items-center justify-center text-sm font-semibold tracking-widest text-slate-600">...</li>
) : (
<li key={item}>
<button
type="button"
onClick={() => setPage(item)}
aria-label={`Page ${item}`}
aria-current={item === page ? "page" : undefined}
className={`h-10 min-w-[40px] px-2 text-sm font-semibold tabular-nums transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2 ${item === page ? "bg-slate-900 text-white" : "text-slate-700 hover:bg-slate-100"}`}
>
{item}
</button>
</li>
),
)}
</ul>
<button type="button" onClick={() => setPage(page + 1)} disabled={page === lastPage} className={step}>
Next
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4"><path d="m8 5 5 5-5 5" /></svg>
</button>
</div>
</nav>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/pagination-with-page-size-select.json
Divide the total number of items by the page size and round up: `Math.ceil(totalItems / pageSize)`. The first row on a page is `(page - 1) * pageSize + 1` and the last is the smaller of `page * pageSize` and the total.
Keep the visitor near the rows they were looking at. This component takes the first row currently shown and moves to the page that contains it at the new size, so going from 10 to 50 rows on page 5 lands on page 1, which still includes row 41.
Use `page` and `pageSize` to slice your array, or send them to your server as `limit` and `offset` values. Fetch again whenever either one changes, and set `totalItems` from the count the server returns.
Pagination suits tables, search results and admin screens, where people want to return to a known place and reach the footer. Infinite scroll suits feeds that are browsed casually. If people need to find a specific row again, use pagination.
For public pages such as a blog index, make each page a real link with its own address, for example `?page=3`, using `a` elements in place of the buttons. Buttons are right for app screens where the list reloads in place.
Related: Sortable Data Table with Status Badges and Row Actions, Underline Tabs with Icons and Three Panels, Button Set: Primary, Secondary, Ghost, Icon and Loading.
More tabs and navigation or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.