More Tables Snippets
Pagination Table — Free HTML CSS JS Snippet
Pagination Table · Tables · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Pagination Table — Client-Side Pagination, Smart Page Numbers, Per-Page Select & Showing X–Y of Z

Pagination is essential for any data table that displays more rows than fit on one screen. Without it, users must scroll through hundreds of rows to find what they need. With well-implemented pagination, users can navigate between pages, control the number of rows shown, and see exactly where they are in the dataset. This snippet provides a complete client-side paginated table: smart page number buttons with ellipsis, prev/next navigation, a rows-per-page selector, and a "Showing X–Y of Z results" info display.
How client-side pagination works
The full dataset (48 sample rows) is stored in a JavaScript array. The render() function computes the current page slice: start = (page-1) * perPage, end = min(start + perPage, total). data.slice(start, end) extracts the visible rows. The function rebuilds the tbody innerHTML and the pagination controls on every page change.
Smart page number range with ellipsis
Instead of showing all page numbers (which would overflow for large datasets), the pagination shows: page 1 (always), pages around the current page (current-1, current, current+1), and the last page (always). Gaps between these numbers are filled with an ellipsis "…" element. A Set deduplicates and sorts the visible page numbers. This pattern matches the standard pagination used by Google, GitHub, and most admin dashboards.
Rows per page selector
A select element offers 5, 10, and 20 rows. Changing the selection calls setPerPage() which updates the perPage variable, resets page to 1, and calls render(). This ensures the user always sees page 1 after changing the row count — they are not left on a page that no longer exists.
The info bar
"Showing 1–10 of 48 results" gives users exact orientation within the dataset. The numbers update dynamically: the start of the current slice (1-indexed), the end of the current slice, and the total count. This pattern appears in virtually every production data table interface.
Customising with real data
Replace the data array with your real data from an API. For server-side pagination, change the fetch call to include page and perPage as query parameters. The render() function receives the total count from the API response header (X-Total-Count or paginated response body) and computes page buttons from that total.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to hand-simulate the page-range algorithm to understand it. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how the Set built from 1, pages, page-1, page, and page+1 produces the visible page-number buttons, and why sorting and diffing consecutive entries in that Set is what decides where an ellipsis gets inserted. The same assistant can help optimize it, for example asking whether rebuilding the entire tbody innerHTML on every page change is fine for 48 rows but should switch to patching only changed rows if the dataset grows into the thousands. It's also useful for extending the table: ask it to add a search input that filters the data array before slicing so pagination and the results count stay correct together, wire data.slice up to a real fetch call for server-side pagination, or add column sorting that runs before the page slice is taken. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a client-side "paginated data table" in plain HTML, CSS, and JavaScript, with no pagination library — pagination must be computed purely from array slicing and a Set-based page-range algorithm.
Requirements:
- An in-memory array of row objects (id, name, email, role, status) as the full dataset, plus a page variable and a perPage variable, both starting at sensible defaults.
- A single render function that computes start as (page minus 1) times perPage and end as the minimum of start plus perPage and the total row count, takes data.slice(start, end) as the currently visible rows, and rewrites the table body's rows from that slice.
- The same render function must rebuild the pagination controls: a Previous button disabled on the first page, a Next button disabled on the last page, and page-number buttons generated from a Set containing exactly the first page, the last page, and the current page along with its immediate neighbor page numbers, deduplicated and sorted; whenever two consecutive numbers in that sorted set differ by more than 1, insert an ellipsis element between their buttons instead of every number in between.
- A rows-per-page select control (offering at least three options) that, on change, updates perPage, resets page back to 1, and calls render — the row count must never leave the user stranded on a now-nonexistent page.
- An info line above the table that reads "Showing X to Y of Z results", recomputed from the same start/end/total values on every render.
- Give each row a status badge (e.g. Active/Inactive) styled differently by status, and make the table wrapper horizontally scrollable for narrow viewports.Step by step
How to Use
- 1Click page numbers or Prev/Next to navigateThe table re-renders to the selected page. The page number button gets an active indigo style. Prev and Next buttons disable when at the first or last page respectively.
- 2Change rows per pageSelect 5, 10, or 20 from the dropdown. The table resets to page 1 with the new row count. The "Showing X–Y of Z" info updates to reflect the new per-page slice.
- 3Replace the sample data arrayIn the JS panel, replace the data array at the top with your own data. Each object needs the properties displayed in the table columns. The pagination and info bar update automatically from data.length.
- 4Implement server-side paginationReplace data.slice(start,end) with a fetch call: fetch("/api/users?page="+page+"&limit="+perPage). Use the API response total count to compute pages: pages = Math.ceil(totalFromApi / perPage). Render the API rows into the tbody.
- 5Add column sortingSee the Sortable Table snippet in this library. Both use the same table structure. Add data-col and data-type attributes to th elements and the sortBy() function to sort the data array before slicing for the current page.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component with useState for page and perPage and a useMemo for the visible slice, or "Tailwind" for a Tailwind CSS version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The algorithm creates a Set containing: 1 (always show first page), pages (always show last page), and page-1, page, page+1 (show current page and neighbours). It filters out values below 1 or above pages. The Set deduplicates (e.g., if page=1, both 1 and page-1=0 are in the input but only 1 makes it through the filter). The sorted array is iterated, and when a gap greater than 1 exists between consecutive numbers, an ellipsis element is inserted.
Remove the data array and data.slice() call. In render(), add: const res = await fetch("/api/rows?page="+page+"&limit="+perPage); const { rows, total } = await res.json(); const pages = Math.ceil(total / perPage). Render rows to the tbody from the API response. Update the info bar from total. The pagination button generation code stays exactly the same — it only needs total and pages, which now come from the API response instead of data.length.
Add a search input above the table. On every input event, filter the data array: const filtered = data.filter(r => r.name.toLowerCase().includes(q) || r.email.toLowerCase().includes(q)). Reset page to 1 on each search. Use filtered instead of data in the render() calculations: start = (page-1)*perPage, rows = filtered.slice(start, end), pages = Math.ceil(filtered.length/perPage). The pagination and info bar automatically reflect the filtered subset.
Click "JSX" to download. Manage page and perPage with useState. Use useMemo to compute the visible rows slice: const visible = useMemo(() => data.slice((page-1)*perPage, page*perPage), [page, perPage]). Use another useMemo for totalPages: Math.ceil(data.length/perPage). Derive the page number range in useMemo as well. Reset page to 1 in useEffect when perPage changes: useEffect(() => setPage(1), [perPage]).