Arrows Pagination
Original · freeprev/next arrow pagination
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"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
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

Compact Pagination
Originalcompact pagination with ellipsis

Input Pagination
Originalpagination with a go-to-page input

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.

