Web InnoventixFreeCode

Input Pagination

Original · free

pagination with a go-to-page input

byWeb InnoventixReact + Tailwind
pageinputpagination
Open

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
page-input.tsx
"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}&ndash;{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">
                            &hellip;
                          </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

motion

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 quote

Similar components

Browse all →