- INV-1040Northwind$180.00Paid
- INV-1041Hooli$3,499.00Overdue
- INV-1042Cyberdyne$2,218.00Due
- INV-1043Initech$937.00Paid
Nothing fetched yet. Rest the pointer on a page for a moment.
"use client"
import * as React from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { PaginationContent, PeekPagination } from "@/components/keyframery/peek-pagination"
// One query per page. The table and the peek card both go through it, so they share a cache.
const invoicesPage = (page: number, size: number) => ({
queryKey: ["invoices", page, size],
queryFn: ({ signal }: { signal: AbortSignal }) =>
fetch(`/api/invoices?page=${page}&size=${size}`, { signal }).then((res) => res.json() as Promise<Invoice[]>),
})
export function Invoices({ total }: { total: number }) {
const [page, setPage] = React.useState(1)
const size = 10
const queryClient = useQueryClient()
const { data: rows = [] } = useQuery(invoicesPage(page, size))
return (
<>
<PaginationContent page={page}>
<InvoiceTable rows={rows} />
</PaginationContent>
<PeekPagination
page={page}
onPageChange={setPage}
total={total}
pageSize={size}
// Pointing at a page fetches it into the same cache, so clicking it is instant.
peek={(p) => queryClient.fetchQuery(invoicesPage(p, size))}
preview={(p, invoices) => <InvoicePeek rows={invoices.slice(0, 3)} />}
/>
</>
)
}Installation
npx shadcn add @keyframery/peek-pagination
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
| page | number | — | The current page, if you're controlling it. |
| defaultPage | number | 1 | Where to start, if you're not. |
| onPageChange | (page: number) => void | — | Runs with the new page. |
| total | number | — | How many items there are. Turns on “Showing 21–30 of 240”. |
| pageSize | number | 10 | Items per page. |
| pages | number | — | The page count, if you'd rather not pass total. Shows “Page 8 of 300”. |
| peek | (page, signal) => Promise<T> | — | Fetches a page for the card. It runs once the pointer has rested on that page for 150 ms, is cancelled through signal if the pointer moves on, and its result is kept. Fetch through your data cache and the page is already loaded when it's clicked. |
| preview | (page, data: T) => ReactNode | — | Draws the card for a page. With peek, data is what peek fetched, and a skeleton shows while it loads. Without peek, render from data you already have. Leave both out and the card shows the page and its range. |
| pageSizes | number[] | — | Page sizes to offer in the rows-per-page switch. |
| onPageSizeChange | (size: number) => void | — | Runs with the new page size. |
| aria-label | string | "Pagination" | What screen readers call it. |
Accessibility and motion
- Peeking is prefetching. The card only fetches once the pointer rests on a page, so sweeping across the row sends nothing, and scrubbing across a gap fetches just the page you stop on. Moving on cancels the request, and every page it fetched is kept, so pointing back costs nothing.
- Your data cache does the rest. The example uses TanStack Query's fetchQuery, so the page you peeked at is already in the cache the table reads, and it opens without a spinner. SWR's preload, or a plain fetch with your own map, work the same way.
- Every page, arrow and gap is a real button with a clear label. The card is extra, so keyboards and screen readers get the full pagination without it, and tabbing to a page fetches that one too. On a touch screen, a tap simply goes to the page.