Pagination Stack
A collection of 7 premium pagination variants featuring spring indicators, neon glows, and liquid blur effects.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add pagination-stackSource Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
const ITEM_SIZE = 44
const GAP = 8
export default function PaginationAurora() {
const [page, setPage] = useState(1)
const total = 7
const pages = Array.from({ length: total }, (_, i) => i + 1)
return (
<div className="flex items-center justify-center p-6">
<div className="rounded-[22px] border border-border bg-background/50 p-1.5 backdrop-blur-md">
<div className="relative flex items-center gap-2 rounded-[18px]">
<motion.div
className="absolute left-0 top-0 rounded-[18px]"
style={{ width: ITEM_SIZE, height: ITEM_SIZE }}
animate={{ x: (page - 1) * (ITEM_SIZE + GAP) }}
transition={{ type: 'spring', stiffness: 320, damping: 26 }}
>
<motion.div
className="absolute inset-0 rounded-[18px]"
style={{
background:
'conic-gradient(from 180deg, rgba(99,102,241,0.25), rgba(34,197,94,0.25), rgba(244,63,94,0.25), rgba(99,102,241,0.25))',
filter: 'blur(16px)',
}}
animate={{ opacity: 0.7 }}
transition={{ duration: 0.3 }}
/>
<motion.div
className="absolute inset-0 rounded-[18px] border border-primary/20 bg-primary/10"
/>
</motion.div>
{pages.map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`relative h-11 grid place-items-center rounded-xl z-10 transition-all duration-200 ${
page === p
? 'text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground'
}`}
style={{ width: ITEM_SIZE }}
>
<motion.span
animate={{ scale: page === p ? 1.05 : 0.95, opacity: page === p ? 1 : 0.75 }}
transition={{ type: 'spring', stiffness: 400, damping: 24 }}
className="text-sm"
>
{p}
</motion.span>
</button>
))}
</div>
</div>
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
export default function PaginationCapsule() {
const [page, setPage] = useState(1)
const total = 7
const pages = Array.from({ length: total }, (_, i) => i + 1)
const w = 44
return (
<div className="flex items-center justify-center p-6">
<div className="relative px-2 py-2 rounded-full bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 shadow-inner">
<div className="relative flex items-center">
<motion.div
layout
className="absolute top-0 bottom-0 rounded-full bg-white dark:bg-zinc-800 shadow-[0_2px_10px_rgba(0,0,0,0.1)] dark:shadow-[0_2px_10px_rgba(0,0,0,0.3)] border border-zinc-200 dark:border-zinc-700"
style={{ width: w, left: (page - 1) * w }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
{pages.map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`relative h-10 grid place-items-center rounded-full z-10 transition-colors duration-200 ${
page === p ? 'text-black dark:text-white font-semibold' : 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
style={{ width: w }}
>
<span className="text-sm">{p}</span>
</button>
))}
</div>
</div>
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
const ITEM_SIZE = 40
const GAP = 4
export default function PaginationFramer() {
const [page, setPage] = useState(1)
const total = 6
const pages = Array.from({ length: total }, (_, i) => i + 1)
return (
<div className="flex items-center justify-center p-6">
<div className="flex items-center gap-2 px-3 py-2 bg-background/40 backdrop-blur-md rounded-2xl border border-border/60 shadow-sm">
<div className="relative flex items-center gap-1">
<motion.div
className="absolute inset-y-0 left-0 rounded-xl bg-primary/10 border border-primary/20 shadow-[0_0_20px_rgba(var(--primary),0.2)]"
style={{ width: ITEM_SIZE }}
animate={{ x: (page - 1) * (ITEM_SIZE + GAP) }}
transition={{ type: 'spring', stiffness: 350, damping: 25 }}
/>
{pages.map((p, i) => (
<button
key={p}
onClick={() => setPage(p)}
className="relative grid h-10 place-items-center rounded-xl z-10"
style={{ width: ITEM_SIZE }}
>
<motion.span
animate={{ scale: page === p ? 1.1 : 0.9, opacity: page === p ? 1 : 0.6 }}
transition={{ type: 'spring', stiffness: 400, damping: 20 }}
className={`text-sm font-bold ${
page === p ? 'text-primary' : 'text-muted-foreground hover:text-foreground hover:scale-105 transition-all'
}`}
>
{p}
</motion.span>
</button>
))}
</div>
</div>
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
const ITEM_SIZE = 36
const GAP = 8
export default function PaginationLiquid() {
const [page, setPage] = useState(1)
const total = 6
const pages = Array.from({ length: total }, (_, i) => i + 1)
return (
<div className="flex items-center justify-center p-6">
<div className="rounded-2xl bg-white dark:bg-neutral-900 border border-black/5 dark:border-white/10 shadow-sm overflow-hidden px-3 py-2">
<div className="relative flex items-center gap-2">
<motion.div
className="absolute left-0 top-1/2 rounded-full blur-xl bg-blue-500/30 dark:bg-blue-500/20 mix-blend-multiply dark:mix-blend-screen"
style={{ width: 60, height: 60, marginTop: -30 }}
animate={{ x: (page - 1) * (ITEM_SIZE + GAP) - 12 }}
transition={{ type: 'spring', stiffness: 220, damping: 25 }}
/>
<motion.div
className="absolute inset-y-0 left-0 rounded-xl bg-blue-50/70 border border-blue-200/70 dark:bg-blue-900/20 dark:border-blue-400/20"
style={{ width: ITEM_SIZE }}
animate={{ x: (page - 1) * (ITEM_SIZE + GAP) }}
transition={{ type: 'spring', stiffness: 260, damping: 28 }}
/>
{pages.map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`relative h-9 grid place-items-center rounded-xl z-10 transition-colors duration-300 ${
page === p ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-300'
}`}
style={{ width: ITEM_SIZE }}
>
<span className="text-sm">{p}</span>
</button>
))}
</div>
</div>
</div>
)
}
Source Code
'use client'
import { useState } from 'react'
import { motion } from 'framer-motion'
const ITEM_SIZE = 40
const GAP = 8
export default function PaginationNeon() {
const [page, setPage] = useState(1)
const total = 6
const pages = Array.from({ length: total }, (_, i) => i + 1)
return (
<div className="flex items-center justify-center p-6 bg-black rounded-3xl">
<div className="rounded-2xl bg-neutral-900/80 border border-neutral-800 shadow-2xl shadow-black/50 backdrop-blur-sm px-3 py-3">
<div className="relative flex items-center gap-2">
<motion.div
className="absolute inset-y-0 left-0 rounded-xl bg-indigo-500/20 border border-indigo-500/50"
style={{
width: ITEM_SIZE,
boxShadow: '0 0 20px 2px rgba(99,102,241,0.4), inset 0 0 10px rgba(99,102,241,0.2)',
}}
animate={{ x: (page - 1) * (ITEM_SIZE + GAP) }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
/>
<motion.div
className="absolute inset-y-[-6px] left-0 rounded-[16px] bg-indigo-500/20 blur-xl"
style={{ width: ITEM_SIZE }}
animate={{ x: (page - 1) * (ITEM_SIZE + GAP) }}
transition={{ type: 'spring', stiffness: 280, damping: 24 }}
/>
{pages.map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`relative h-10 grid place-items-center rounded-xl transition-colors duration-300 z-10 ${
page === p ? 'text-white font-bold' : 'text-neutral-500 hover:text-neutral-300'
}`}
style={{ width: ITEM_SIZE }}
>
<span className="relative z-10">{p}</span>
</button>
))}
</div>
</div>
</div>
)
}
Source Code
'use client'
import React, { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import PaginationClassic from './pagination'
import PaginationFramer from './framer-pagination'
import PaginationNeon from './neon-pagination'
import PaginationLiquid from './liquid-pagination'
import PaginationCapsule from './capsule-pagination'
export default function PaginationStack() {
const [copied, setCopied] = useState<string | null>(null)
const copy = (name: string, cmd: string) => {
navigator.clipboard.writeText(cmd)
setCopied(name)
setTimeout(() => setCopied(null), 1500)
}
const variants = [
{ name: 'Classic', Component: PaginationClassic, cmd: 'npx vui-registry-cli-v1 add pagination' },
{ name: 'Framer', Component: PaginationFramer, cmd: 'npx vui-registry-cli-v1 add framer-pagination' },
{ name: 'Neon', Component: PaginationNeon, cmd: 'npx vui-registry-cli-v1 add neon-pagination' },
{ name: 'Liquid', Component: PaginationLiquid, cmd: 'npx vui-registry-cli-v1 add liquid-pagination' },
{ name: 'Capsule', Component: PaginationCapsule, cmd: 'npx vui-registry-cli-v1 add capsule-pagination' },
]
return (
<div className="w-full grid gap-6 md:grid-cols-2 p-4">
{variants.map((v) => (
<div key={v.name} className="rounded-2xl p-[2px] border border-border bg-card/50 backdrop-blur-sm overflow-hidden">
<div className="rounded-xl border border-border bg-background/60">
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-muted/10 relative">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{v.name}</div>
<button
onClick={() => copy(v.name, v.cmd)}
className="text-[10px] font-mono border-b border-border absolute top-2 right-3"
>
<AnimatePresence mode="wait">
{copied === v.name ? (
<motion.span key="copied" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="text-emerald-500">
Copied
</motion.span>
) : (
<motion.span key="copy" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
CLI
</motion.span>
)}
</AnimatePresence>
</button>
</div>
<div className="p-6 min-h-[160px] grid place-items-center">
<v.Component />
</div>
</div>
</div>
))}
</div>
)
}
Source Code
'use client'
import React, { useState } from 'react'
import { motion } from 'framer-motion'
import { ChevronLeft, ChevronRight } from 'lucide-react'
export default function PaginationClassic() {
const [page, setPage] = useState(1)
const total = 7
const pages = Array.from({ length: total }, (_, i) => i + 1)
return (
<div className="flex items-center justify-center p-6">
<div className="flex items-center gap-1 p-1.5 bg-background/50 backdrop-blur-sm rounded-2xl border border-border shadow-sm">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2.5 rounded-xl hover:bg-foreground/5 disabled:opacity-30 transition-all active:scale-95"
>
<ChevronLeft size={16} className="text-muted-foreground" />
</button>
<div className="flex items-center relative gap-1">
{pages.map((p, i) => (
<button
key={p}
onClick={() => setPage(p)}
className={`relative w-9 h-9 flex items-center justify-center text-sm font-medium rounded-xl z-10 transition-colors duration-300 ${
page === p ? 'text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-foreground/5'
}`}
>
{page === p && (
<motion.div
layoutId="pagination-classic"
className="absolute inset-0 rounded-xl bg-primary shadow-lg shadow-primary/20"
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
/>
)}
<span className="relative">{p}</span>
</button>
))}
</div>
<button
onClick={() => setPage((p) => Math.min(total, p + 1))}
disabled={page === total}
className="p-2.5 rounded-xl hover:bg-foreground/5 disabled:opacity-30 transition-all active:scale-95"
>
<ChevronRight size={16} className="text-muted-foreground" />
</button>
</div>
</div>
)
}
Source Code
'use client'
import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { ChevronLeft, ChevronRight } from 'lucide-react'
const ITEM_SIZE = 40
const GAP = 8
export default function PaginationScroll() {
const [page, setPage] = useState(1)
const total = 8
const scroller = useRef<HTMLDivElement | null>(null)
const pages = Array.from({ length: total }, (_, i) => i + 1)
function scrollToPage(next: number) {
const el = scroller.current
if (!el) return
const left = Math.max(0, (next - 1) * (ITEM_SIZE + GAP) - (el.clientWidth - ITEM_SIZE) / 2)
el.scrollTo({ left, behavior: 'smooth' })
}
function go(dir: -1 | 1) {
const next = Math.min(total, Math.max(1, page + dir))
setPage(next)
scrollToPage(next)
}
useEffect(() => {
scrollToPage(page)
}, [page])
return (
<div className="flex items-center justify-center p-6">
<div className="flex items-center gap-2">
<button
onClick={() => go(-1)}
disabled={page === 1}
className="p-3 rounded-xl border border-border/50 bg-background/50 backdrop-blur-sm text-muted-foreground hover:bg-foreground/5 hover:text-foreground disabled:opacity-30 transition-all active:scale-95"
>
<ChevronLeft size={16} />
</button>
<div className="relative p-1 rounded-2xl border border-border/50 bg-background/30 backdrop-blur-sm shadow-inner">
<div
ref={scroller}
className="flex items-center overflow-x-auto scroll-smooth snap-x snap-mandatory gap-2 px-1 py-1 no-scrollbar w-[240px]"
>
<div className="relative flex w-max items-center gap-2">
<motion.div
className="absolute left-0 top-1/2 h-10 -translate-y-1/2 rounded-xl border border-primary/20 bg-primary/10 pointer-events-none"
style={{ width: ITEM_SIZE }}
animate={{ x: (page - 1) * (ITEM_SIZE + GAP) }}
transition={{ type: 'spring', stiffness: 350, damping: 25 }}
/>
{pages.map((p) => (
<button
key={p}
onClick={() => {
setPage(p)
scrollToPage(p)
}}
className={`h-10 flex-shrink-0 grid place-items-center rounded-xl snap-center transition-all duration-300 ${
page === p
? 'text-foreground font-semibold scale-100'
: 'text-muted-foreground hover:bg-foreground/5 scale-[0.94] opacity-70 hover:opacity-100'
}`}
style={{ width: ITEM_SIZE }}
>
{p}
</button>
))}
</div>
</div>
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none" />
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none" />
</div>
<button
onClick={() => go(1)}
disabled={page === total}
className="p-3 rounded-xl border border-border/50 bg-background/50 backdrop-blur-sm text-muted-foreground hover:bg-foreground/5 hover:text-foreground disabled:opacity-30 transition-all active:scale-95"
>
<ChevronRight size={16} />
</button>
</div>
<style jsx>{`
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
`}</style>
</div>
)
}
Source Code
'use client'
import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
const THUMB_SIZE = 20
export default function PaginationSlider() {
const [page, setPage] = useState(1)
const total = 10
const progress = total > 1 ? (page - 1) / (total - 1) : 0
const trackRef = useRef<HTMLDivElement | null>(null)
const [trackWidth, setTrackWidth] = useState(0)
useEffect(() => {
const el = trackRef.current
if (!el) return
const update = () => setTrackWidth(el.clientWidth)
update()
const observer = new ResizeObserver(update)
observer.observe(el)
return () => observer.disconnect()
}, [])
const travelWidth = Math.max(trackWidth - THUMB_SIZE, 0)
const thumbX = progress * travelWidth
const fillWidth = thumbX + THUMB_SIZE / 2
return (
<div className="flex flex-col items-center justify-center p-8 gap-6 w-full max-w-md mx-auto">
<div className="flex items-center justify-between w-full text-xs font-medium text-muted-foreground">
<span>Page {page}</span>
<span>{total} pages</span>
</div>
<div className="relative w-full h-10 flex items-center">
<div
ref={trackRef}
className="absolute left-[10px] right-[10px] top-1/2 h-1.5 -translate-y-1/2 bg-neutral-200 dark:bg-neutral-800 rounded-full"
>
<motion.div
className="absolute left-0 top-0 bottom-0 bg-primary rounded-full"
animate={{ width: fillWidth }}
transition={{ type: 'spring', stiffness: 400, damping: 32 }}
/>
<motion.div
className="absolute left-0 top-1/2 bg-white dark:bg-neutral-900 border-2 border-primary rounded-full shadow-md pointer-events-none"
animate={{ x: thumbX }}
transition={{ type: 'spring', stiffness: 450, damping: 30 }}
style={{
width: THUMB_SIZE,
height: THUMB_SIZE,
marginLeft: -THUMB_SIZE / 2,
marginTop: -THUMB_SIZE / 2,
}}
/>
</div>
<input
type="range"
min={1}
max={total}
step={1}
value={page}
onChange={(e) => setPage(Number(e.target.value))}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
aria-label="Select page"
/>
</div>
<div className="flex items-center justify-center gap-1 w-full flex-wrap">
{Array.from({ length: total }, (_, i) => i + 1).map((p) => (
<button
key={p}
type="button"
onClick={() => setPage(p)}
className={`min-w-[32px] h-8 px-2 grid place-items-center rounded-lg text-xs font-medium transition-all duration-200 ${
page === p
? 'bg-primary text-primary-foreground shadow-sm scale-105'
: 'text-muted-foreground hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
{p}
</button>
))}
</div>
</div>
)
}
Dependencies
framer-motion: latestlucide-react: latestclsx: latesttailwind-merge: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| currentPage | number | 1 | The currently active page. |
| totalPages | number | 10 | Total number of pages available. |
| onPageChange | (page: number) => void | undefined | Callback function when a page is selected. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.