Input Pagination
Original · freepagination with a go-to-page input
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/page-input.json"use client";
import { useId, useMemo, useState } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "Open" | "In progress" | "Waiting" | "Resolved";
type Priority = "Urgent" | "High" | "Medium" | "Low";
type Ticket = {
subject: string;
area: string;
status: Status;
priority: Priority;
updated: string;
};
type PageEntry = number | "left-dots" | "right-dots";
const TICKETS: Ticket[] = [
{ subject: "Billing webhook retries exhausted after repeated 429s", area: "Billing", status: "Open", priority: "Urgent", updated: "4m ago" },
{ subject: "SSO login loops back to sign-in on Safari 17", area: "Auth", status: "In progress", priority: "High", updated: "18m ago" },
{ subject: "CSV export truncates rows past 10,000", area: "Dashboard", status: "Open", priority: "Medium", updated: "33m ago" },
{ subject: "API returns 500 when the expand parameter is empty", area: "API", status: "In progress", priority: "High", updated: "52m ago" },
{ subject: "Invoice PDF shows the wrong tax region for EU customers", area: "Billing", status: "Waiting", priority: "High", updated: "1h ago" },
{ subject: "Push notifications arrive silently on Android 14", area: "Mobile", status: "Open", priority: "Medium", updated: "1h ago" },
{ subject: "Rate-limit headers missing on batch endpoints", area: "API", status: "Resolved", priority: "Low", updated: "2h ago" },
{ subject: "Two-factor codes rejected during clock skew", area: "Auth", status: "In progress", priority: "Urgent", updated: "2h ago" },
{ subject: "Dark-mode chart labels unreadable after export", area: "Dashboard", status: "Open", priority: "Low", updated: "3h ago" },
{ subject: "Webhook signature mismatch after key rotation", area: "Webhooks", status: "Waiting", priority: "High", updated: "3h ago" },
{ subject: "Seat count not decremented after member removal", area: "Billing", status: "Open", priority: "Medium", updated: "4h ago" },
{ subject: "Deep links open a blank screen on cold start", area: "Mobile", status: "In progress", priority: "Medium", updated: "5h ago" },
{ subject: "Pagination cursor skips the last record on reverse sort", area: "API", status: "Open", priority: "High", updated: "5h ago" },
{ subject: "Password reset email delayed by 20+ minutes", area: "Auth", status: "Resolved", priority: "Medium", updated: "6h ago" },
{ subject: "Timezone selector reverts to UTC on save", area: "Dashboard", status: "Open", priority: "Low", updated: "7h ago" },
{ subject: "Proration credit missing on mid-cycle downgrade", area: "Billing", status: "Waiting", priority: "High", updated: "8h ago" },
{ subject: "Webhook delivery paused after three consecutive timeouts", area: "Webhooks", status: "In progress", priority: "Urgent", updated: "9h ago" },
{ subject: "Biometric unlock disabled after the latest app update", area: "Mobile", status: "Open", priority: "Medium", updated: "10h ago" },
{ subject: "GraphQL depth limit blocks nested team queries", area: "API", status: "Open", priority: "Medium", updated: "11h ago" },
{ subject: "Magic link expires before it reaches the inbox", area: "Auth", status: "Waiting", priority: "High", updated: "12h ago" },
{ subject: "Saved filters vanish after a browser refresh", area: "Dashboard", status: "Open", priority: "Low", updated: "14h ago" },
{ subject: "Duplicate charge on a retried checkout session", area: "Billing", status: "In progress", priority: "Urgent", updated: "16h ago" },
{ subject: "Webhook payloads missing the created_at field", area: "Webhooks", status: "Open", priority: "Medium", updated: "18h ago" },
{ subject: "Offline queue drops edits beyond 50 items", area: "Mobile", status: "Waiting", priority: "Medium", updated: "20h ago" },
{ subject: "Idempotency key collides across workspaces", area: "API", status: "In progress", priority: "High", updated: "22h ago" },
{ subject: "Session cookie not cleared on server-side logout", area: "Auth", status: "Open", priority: "High", updated: "1d ago" },
{ subject: "Bulk import stalls at 95% with no error", area: "Dashboard", status: "Open", priority: "Medium", updated: "1d ago" },
{ subject: "Refund fails silently for partially captured payments", area: "Billing", status: "Waiting", priority: "High", updated: "1d ago" },
{ subject: "Webhook retries ignore the custom backoff config", area: "Webhooks", status: "Resolved", priority: "Low", updated: "2d ago" },
{ subject: "Camera upload rotates portrait photos sideways", area: "Mobile", status: "Open", priority: "Low", updated: "2d ago" },
{ subject: "Search index lags 10+ minutes behind writes", area: "API", status: "In progress", priority: "Medium", updated: "2d ago" },
{ subject: "Team invite accepted but the role stays pending", area: "Auth", status: "Open", priority: "Medium", updated: "3d ago" },
{ subject: "Retry storm floods the webhook endpoint after an outage", area: "Webhooks", status: "In progress", priority: "High", updated: "3d ago" },
{ subject: "Metric totals disagree between dashboard and export", area: "Dashboard", status: "Open", priority: "Medium", updated: "3d ago" },
{ subject: "Access token refresh fails under concurrent requests", area: "Auth", status: "In progress", priority: "High", updated: "4d ago" },
{ subject: "Sandbox keys accepted on the production endpoint", area: "API", status: "Waiting", priority: "Urgent", updated: "4d ago" },
{ subject: "In-app purchase receipt not validated on restore", area: "Mobile", status: "Open", priority: "Medium", updated: "4d ago" },
{ subject: "Coupon stacks beyond the configured redemption limit", area: "Billing", status: "Open", priority: "High", updated: "5d ago" },
{ subject: "Chart tooltip clips at the right edge on 4K displays", area: "Dashboard", status: "Resolved", priority: "Low", updated: "5d ago" },
{ subject: "Webhook secret shown in plaintext in the audit log", area: "Webhooks", status: "In progress", priority: "Urgent", updated: "5d ago" },
{ subject: "Signup blocked for emails using plus-addressing", area: "Auth", status: "Open", priority: "Medium", updated: "6d ago" },
{ subject: "List endpoint ignores limit values above 100", area: "API", status: "Waiting", priority: "Low", updated: "6d ago" },
{ subject: "Crash on rotate while the video preview is loading", area: "Mobile", status: "Open", priority: "High", updated: "6d ago" },
{ subject: "Annual plan renews at the monthly price after upgrade", area: "Billing", status: "In progress", priority: "Urgent", updated: "7d ago" },
{ subject: "Scheduled reports skip months with 31 days", area: "Dashboard", status: "Open", priority: "Medium", updated: "7d ago" },
{ subject: "Webhook events arrive out of order under high volume", area: "Webhooks", status: "Open", priority: "High", updated: "8d ago" },
{ subject: "Recovery codes reusable after a single redemption", area: "Auth", status: "In progress", priority: "Urgent", updated: "8d ago" },
{ subject: "Cursor pagination returns duplicates near the boundary", area: "API", status: "Open", priority: "Medium", updated: "9d ago" },
];
const PAGE_SIZE = 6;
const statusStyles: Record<Status, string> = {
Open: "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/20",
"In progress": "bg-violet-50 text-violet-700 ring-violet-600/20 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/20",
Waiting: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20",
Resolved: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20",
};
const priorityDot: Record<Priority, string> = {
Urgent: "bg-rose-500",
High: "bg-amber-500",
Medium: "bg-sky-500",
Low: "bg-slate-400 dark:bg-slate-500",
};
function range(start: number, end: number): number[] {
const out: number[] = [];
for (let i = start; i <= end; i += 1) out.push(i);
return out;
}
function buildPages(current: number, total: number): PageEntry[] {
const siblingCount = 1;
const totalNumbers = siblingCount * 2 + 5;
if (total <= totalNumbers) return range(1, total);
const leftSibling = Math.max(current - siblingCount, 1);
const rightSibling = Math.min(current + siblingCount, total);
const showLeftDots = leftSibling > 2;
const showRightDots = rightSibling < total - 1;
const edgeCount = 3 + siblingCount * 2;
if (!showLeftDots && showRightDots) {
return [...range(1, edgeCount), "right-dots", total];
}
if (showLeftDots && !showRightDots) {
return [1, "left-dots", ...range(total - edgeCount + 1, total)];
}
return [1, "left-dots", ...range(leftSibling, rightSibling), "right-dots", total];
}
export default function PageInputPagination() {
const reduce = useReducedMotion();
const goId = useId();
const errorId = useId();
const [currentPage, setCurrentPage] = useState(1);
const [goValue, setGoValue] = useState("");
const [error, setError] = useState("");
const [shaking, setShaking] = useState(false);
const totalItems = TICKETS.length;
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
const start = (currentPage - 1) * PAGE_SIZE;
const firstShown = start + 1;
const lastShown = Math.min(start + PAGE_SIZE, totalItems);
const rows = useMemo(() => TICKETS.slice(start, start + PAGE_SIZE), [start]);
const pages = useMemo(() => buildPages(currentPage, totalPages), [currentPage, totalPages]);
function goToPage(page: number) {
const clamped = Math.min(Math.max(page, 1), totalPages);
setCurrentPage(clamped);
}
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const digits = event.target.value.replace(/\D/g, "").slice(0, 4);
setGoValue(digits);
if (error) setError("");
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmed = goValue.trim();
const parsed = Number(trimmed);
if (trimmed === "" || !Number.isInteger(parsed) || parsed < 1 || parsed > totalPages) {
setError(`Enter a number from 1 to ${totalPages}.`);
if (!reduce) setShaking(true);
return;
}
setError("");
setGoValue("");
goToPage(parsed);
};
const navBtn =
"inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-offset-slate-900";
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
<style>{`
@keyframes pgin-shake {
10%, 90% { transform: translateX(-1px); }
20%, 80% { transform: translateX(2px); }
30%, 50%, 70% { transform: translateX(-4px); }
40%, 60% { transform: translateX(4px); }
}
.pgin-shake { animation: pgin-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
@media (prefers-reduced-motion: reduce) {
.pgin-shake { animation: none; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<div className="mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Customer support
</p>
<h1 className="mt-2 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Ticket queue
</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-500 dark:text-slate-400">
Browse the full backlog page by page, or jump straight to any page with the go-to
field below the table.
</p>
</div>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col gap-4 border-b border-slate-200 px-5 py-5 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Open tickets</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
{totalItems} conversations across billing, auth, API, and mobile.
</p>
</div>
<span className="inline-flex items-center gap-2 self-start rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700 ring-1 ring-inset ring-emerald-600/20 sm:self-auto dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true" />
Live feed
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<caption className="sr-only">
Support tickets, page {currentPage} of {totalPages}.
</caption>
<thead>
<tr className="border-b border-slate-200 dark:border-slate-800">
<th scope="col" className="hidden px-5 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 sm:table-cell dark:text-slate-400">
Ref
</th>
<th scope="col" className="px-5 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Subject
</th>
<th scope="col" className="hidden px-5 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 lg:table-cell dark:text-slate-400">
Area
</th>
<th scope="col" className="hidden px-5 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 md:table-cell dark:text-slate-400">
Priority
</th>
<th scope="col" className="px-5 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Status
</th>
<th scope="col" className="hidden px-5 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500 sm:table-cell dark:text-slate-400">
Updated
</th>
</tr>
</thead>
<AnimatePresence mode="wait" initial={false}>
<motion.tbody
key={currentPage}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduce ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
className="divide-y divide-slate-100 dark:divide-slate-800"
>
{rows.map((ticket, index) => {
const refNumber = 4899 - (start + index);
return (
<tr
key={ticket.subject}
className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"
>
<td className="hidden whitespace-nowrap px-5 py-3.5 font-mono text-xs text-slate-400 sm:table-cell dark:text-slate-500">
#{refNumber}
</td>
<td className="px-5 py-3.5">
<span
title={ticket.subject}
className="block max-w-[15rem] truncate text-sm font-medium text-slate-800 sm:max-w-[22rem] dark:text-slate-100"
>
{ticket.subject}
</span>
</td>
<td className="hidden whitespace-nowrap px-5 py-3.5 text-sm text-slate-500 lg:table-cell dark:text-slate-400">
{ticket.area}
</td>
<td className="hidden whitespace-nowrap px-5 py-3.5 md:table-cell">
<span className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<span className={`h-2 w-2 rounded-full ${priorityDot[ticket.priority]}`} aria-hidden="true" />
{ticket.priority}
</span>
</td>
<td className="whitespace-nowrap px-5 py-3.5">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${statusStyles[ticket.status]}`}>
{ticket.status}
</span>
</td>
<td className="hidden whitespace-nowrap px-5 py-3.5 text-right text-xs text-slate-400 sm:table-cell dark:text-slate-500">
{ticket.updated}
</td>
</tr>
);
})}
</motion.tbody>
</AnimatePresence>
</table>
</div>
<div className="flex flex-col gap-5 border-t border-slate-200 px-5 py-4 dark:border-slate-800 lg:flex-row lg:items-center lg:justify-between">
<p className="text-sm text-slate-500 dark:text-slate-400">
Showing{" "}
<span className="font-semibold text-slate-700 dark:text-slate-200">
{firstShown}–{lastShown}
</span>{" "}
of{" "}
<span className="font-semibold text-slate-700 dark:text-slate-200">{totalItems}</span>{" "}
tickets
</p>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-6">
<nav aria-label="Pagination">
<ul className="flex items-center gap-1">
<li>
<button
type="button"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
className={navBtn}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M15 19l-7-7 7-7" />
</svg>
</button>
</li>
{pages.map((entry) => {
if (entry === "left-dots" || entry === "right-dots") {
return (
<li key={entry}>
<span className="inline-flex h-9 min-w-9 items-center justify-center px-1 text-slate-400 dark:text-slate-500" aria-hidden="true">
…
</span>
</li>
);
}
const active = entry === currentPage;
return (
<li key={`p-${entry}`}>
<button
type="button"
onClick={() => goToPage(entry)}
aria-current={active ? "page" : undefined}
aria-label={`Page ${entry}`}
className={`relative inline-flex h-9 min-w-9 items-center justify-center rounded-lg px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
active
? "text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
}`}
>
{active && (
<motion.span
layoutId="pgin-active"
className="absolute inset-0 rounded-lg bg-indigo-600 dark:bg-indigo-500"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 40 }}
aria-hidden="true"
/>
)}
<span className="relative z-10">{entry}</span>
</button>
</li>
);
})}
<li>
<button
type="button"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next page"
className={navBtn}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 5l7 7-7 7" />
</svg>
</button>
</li>
</ul>
</nav>
<div className="flex flex-col gap-1">
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<label htmlFor={goId} className="whitespace-nowrap text-sm text-slate-500 dark:text-slate-400">
Go to page
</label>
<input
id={goId}
type="text"
inputMode="numeric"
autoComplete="off"
value={goValue}
onChange={handleChange}
onAnimationEnd={() => setShaking(false)}
placeholder={String(currentPage)}
aria-label={`Go to page, a number from 1 to ${totalPages}`}
aria-invalid={error ? true : undefined}
aria-describedby={errorId}
className={`h-9 w-16 rounded-lg border bg-white px-3 text-center text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-slate-950 dark:text-white dark:placeholder:text-slate-500 dark:focus-visible:ring-offset-slate-900 ${
error ? "border-rose-400 dark:border-rose-500" : "border-slate-300 dark:border-slate-700"
} ${shaking ? "pgin-shake" : ""}`}
/>
<button
type="submit"
className="inline-flex h-9 items-center justify-center rounded-lg bg-indigo-600 px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:bg-indigo-700 dark:focus-visible:ring-offset-slate-900"
>
Go
</button>
</form>
<p
id={errorId}
role="status"
aria-live="polite"
className="min-h-[1.15rem] text-xs text-rose-600 dark:text-rose-400"
>
{error}
</p>
</div>
</div>
</div>
</div>
<p className="sr-only" role="status" aria-live="polite">
Showing page {currentPage} of {totalPages}.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Pagination
Originalbasic numbered pagination

Rounded Pagination
Originalrounded pagination buttons

Arrows Pagination
Originalprev/next arrow pagination

Compact Pagination
Originalcompact pagination with ellipsis

Load More Pagination
Originalload-more button with progress

Dots Pagination
Originaldot pagination indicators

With Info Pagination
Originalpagination with results info

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

