Web InnoventixFreeCode

Arrows Pagination

Original · free

prev/next arrow pagination

byWeb InnoventixReact + Tailwind
pagearrowspagination
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-arrows.json
page-arrows.tsx
"use client";

import { useId, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Dispatch = {
  tag: string;
  title: string;
  body: string;
  metricLabel: string;
  metricValue: string;
  author: string;
  date: string;
};

const DISPATCHES: Dispatch[] = [
  {
    tag: "Serverless",
    title: "Cutting cold starts from 1.2s to 400ms",
    body: "We were bundling the entire ORM into every function. Splitting the client from the schema and lazy-loading the driver on first query shaved 800ms off the initial request — enough to silence the cold-start timeout alerts for good.",
    metricLabel: "p95 cold start",
    metricValue: "-67%",
    author: "Priya Nadar",
    date: "Mar 2026",
  },
  {
    tag: "Databases",
    title: "The migration that locked the orders table",
    body: "An ALTER TABLE with a default value grabbed a full lock on a 40M-row table at 2pm on a Tuesday. Backfilling the column in small batches behind a nullable default turned a nine-minute outage into a change nobody even noticed.",
    metricLabel: "customer downtime",
    metricValue: "0 min",
    author: "Marcus Bell",
    date: "Feb 2026",
  },
  {
    tag: "Observability",
    title: "Why our p99 was quietly lying to us",
    body: "Averaged across three regions the p99 looked healthy. Splitting the histogram per region exposed one datacenter serving four times the tail latency — a single saturated connection pool hiding inside an all-green dashboard.",
    metricLabel: "hidden tail latency",
    metricValue: "4.1x",
    author: "Ana Krol",
    date: "Jan 2026",
  },
  {
    tag: "Refactoring",
    title: "Renaming one field across 41 services",
    body: "The column was called usr_ref and everyone hated it. A codemod plus a two-week dual-write window let us rename it to account_id without a single coordinated deploy, a frozen release, or a rollback.",
    metricLabel: "services touched",
    metricValue: "41",
    author: "Devon Ochoa",
    date: "Dec 2025",
  },
  {
    tag: "Caching",
    title: "The cache entry that refused to expire",
    body: "A typo set the TTL in seconds where the client expected milliseconds, so 'five minutes' quietly became eighty hours. Customers saw stale prices until someone flushed Redis by hand. We now assert TTL units in a unit test.",
    metricLabel: "stale window",
    metricValue: "80h → 5m",
    author: "Lena Fischer",
    date: "Nov 2025",
  },
];

export default function PageArrows() {
  const prefersReduced = useReducedMotion();
  const baseId = useId();
  const total = DISPATCHES.length;

  const [index, setIndex] = useState(0);
  const [direction, setDirection] = useState(0);

  const current = DISPATCHES[index];
  const atStart = index === 0;
  const atEnd = index === total - 1;

  function goTo(next: number) {
    if (next < 0 || next > total - 1 || next === index) return;
    setDirection(next > index ? 1 : -1);
    setIndex(next);
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    switch (event.key) {
      case "ArrowLeft":
        event.preventDefault();
        goTo(index - 1);
        break;
      case "ArrowRight":
        event.preventDefault();
        goTo(index + 1);
        break;
      case "Home":
        event.preventDefault();
        goTo(0);
        break;
      case "End":
        event.preventDefault();
        goTo(total - 1);
        break;
      default:
        break;
    }
  }

  const distance = prefersReduced ? 0 : 44;
  const cardTransition = {
    duration: prefersReduced ? 0 : 0.42,
    ease: [0.22, 1, 0.36, 1] as [number, number, number, number],
  };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 py-20 px-4 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes pgar-float {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.55; }
          50%      { transform: translate3d(0, -8px, 0) scale(1.05); opacity: 0.8; }
        }
        @keyframes pgar-drift {
          0%, 100% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.45; }
          50%      { transform: translate3d(0, 10px, 0) scale(1.08); opacity: 0.7; }
        }
        .pgar-blob { will-change: transform, opacity; }
        .pgar-float { animation: pgar-float 9s ease-in-out infinite; }
        .pgar-drift { animation: pgar-drift 11s ease-in-out infinite; }
        .pgar-dot { transition: width 300ms cubic-bezier(0.22, 1, 0.36, 1), background-color 200ms ease; }
        @media (prefers-reduced-motion: reduce) {
          .pgar-float, .pgar-drift { animation: none !important; }
          .pgar-dot { transition: none !important; }
        }
      `}</style>

      {/* Atmospheric background */}
      <div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="pgar-blob pgar-float absolute -left-16 top-8 h-72 w-72 rounded-full bg-indigo-300/40 blur-3xl dark:bg-indigo-600/20" />
        <div className="pgar-blob pgar-drift absolute -right-20 bottom-4 h-80 w-80 rounded-full bg-violet-300/40 blur-3xl dark:bg-violet-700/20" />
        <div
          className="absolute inset-0 opacity-[0.04] dark:opacity-[0.06]"
          style={{
            backgroundImage:
              "linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
            backgroundSize: "72px 72px",
          }}
        />
      </div>

      <div className="relative mx-auto max-w-2xl">
        {/* Kicker */}
        <div className="mb-8 flex items-center justify-between gap-4">
          <div className="flex items-center gap-2.5">
            <span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
            <span className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">
              Field Notes
            </span>
          </div>
          <span
            className="font-mono text-sm tabular-nums text-slate-400 dark:text-slate-500"
            aria-hidden
          >
            {String(index + 1).padStart(2, "0")}
            <span className="mx-1 text-slate-300 dark:text-slate-600">/</span>
            {String(total).padStart(2, "0")}
          </span>
        </div>

        {/* Progress track */}
        <div className="mb-8 h-1 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
          <motion.div
            className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
            style={{ transformOrigin: "left" }}
            initial={false}
            animate={{ scaleX: (index + 1) / total }}
            transition={{ duration: prefersReduced ? 0 : 0.5, ease: "easeOut" }}
          />
        </div>

        {/* Reader */}
        <div
          role="group"
          aria-roledescription="carousel"
          aria-label="Engineering field notes"
          onKeyDown={onKeyDown}
          className="group/reader relative"
        >
          <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white/80 shadow-[0_1px_0_0_rgba(15,23,42,0.04),0_24px_48px_-24px_rgba(15,23,42,0.25)] backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-[0_24px_48px_-24px_rgba(0,0,0,0.7)]">
            <AnimatePresence mode="wait" custom={direction} initial={false}>
              <motion.article
                key={index}
                custom={direction}
                variants={{
                  enter: (dir: number) => ({ opacity: 0, x: dir >= 0 ? distance : -distance }),
                  center: { opacity: 1, x: 0 },
                  exit: (dir: number) => ({ opacity: 0, x: dir >= 0 ? -distance : distance }),
                }}
                initial="enter"
                animate="center"
                exit="exit"
                transition={cardTransition}
                aria-roledescription="slide"
                aria-label={`Note ${index + 1} of ${total}`}
                className="flex min-h-[20rem] flex-col p-7 sm:min-h-[19rem] sm:p-9"
              >
                <div className="mb-5 flex items-center gap-3">
                  <span className="inline-flex items-center rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-semibold tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
                    {current.tag}
                  </span>
                  <span className="h-px flex-1 bg-gradient-to-r from-slate-200 to-transparent dark:from-slate-700" />
                </div>

                <h3 className="text-2xl font-semibold leading-tight tracking-tight text-slate-900 sm:text-[1.7rem] dark:text-white">
                  {current.title}
                </h3>

                <p className="mt-4 text-[0.975rem] leading-relaxed text-slate-600 dark:text-slate-300">
                  {current.body}
                </p>

                <div className="mt-auto flex flex-wrap items-end justify-between gap-4 pt-7">
                  <div className="flex items-baseline gap-2.5">
                    <span className="text-2xl font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">
                      {current.metricValue}
                    </span>
                    <span className="text-xs uppercase tracking-wide text-slate-400 dark:text-slate-500">
                      {current.metricLabel}
                    </span>
                  </div>
                  <div className="text-right text-xs text-slate-400 dark:text-slate-500">
                    <div className="font-medium text-slate-600 dark:text-slate-300">
                      {current.author}
                    </div>
                    <div className="tabular-nums">{current.date}</div>
                  </div>
                </div>
              </motion.article>
            </AnimatePresence>
          </div>

          {/* Controls */}
          <div className="mt-7 flex items-center justify-between gap-4">
            <button
              type="button"
              onClick={() => goTo(index - 1)}
              disabled={atStart}
              aria-label="Previous note"
              className="inline-flex h-12 w-12 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-700 transition-colors hover:border-indigo-400 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-slate-300 disabled:hover:text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-950 dark:disabled:hover:border-slate-700 dark:disabled:hover:text-slate-200"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-5 w-5"
                aria-hidden
              >
                <path d="M15 18l-6-6 6-6" />
              </svg>
            </button>

            {/* Dot navigation */}
            <div
              role="tablist"
              aria-label="Choose a note"
              className="flex items-center gap-2.5"
            >
              {DISPATCHES.map((item, i) => {
                const active = i === index;
                return (
                  <button
                    key={item.title}
                    type="button"
                    role="tab"
                    id={`${baseId}-tab-${i}`}
                    aria-selected={active}
                    aria-label={`Note ${i + 1}: ${item.title}`}
                    onClick={() => goTo(i)}
                    className={`pgar-dot h-2.5 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                      active
                        ? "w-8 bg-indigo-500 dark:bg-indigo-400"
                        : "w-2.5 bg-slate-300 hover:bg-slate-400 dark:bg-slate-700 dark:hover:bg-slate-600"
                    }`}
                  />
                );
              })}
            </div>

            <button
              type="button"
              onClick={() => goTo(index + 1)}
              disabled={atEnd}
              aria-label="Next note"
              className="inline-flex h-12 w-12 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-700 transition-colors hover:border-indigo-400 hover:text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-slate-300 disabled:hover:text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-950 dark:disabled:hover:border-slate-700 dark:disabled:hover:text-slate-200"
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-5 w-5"
                aria-hidden
              >
                <path d="M9 6l6 6-6 6" />
              </svg>
            </button>
          </div>

          {/* Keyboard hint */}
          <p className="mt-5 text-center text-xs text-slate-400 dark:text-slate-600">
            Use{" "}
            <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[0.7rem] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
              ←
            </kbd>{" "}
            <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[0.7rem] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
              →
            </kbd>{" "}
            to move between notes
          </p>
        </div>

        {/* Screen-reader live announcement */}
        <div className="sr-only" aria-live="polite" aria-atomic="true">
          {`Note ${index + 1} of ${total}: ${current.title}`}
        </div>
      </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 →