Menu

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:

terminal
npx -y vui-registry-cli-v1@latest add pagination-stack

Source Code

aurora-pagination.tsx
 '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

capsule-pagination.tsx
'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

framer-pagination.tsx
'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

liquid-pagination.tsx
'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

neon-pagination.tsx
'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

pagination-stack.tsx
'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

pagination.tsx
'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

scroll-pagination.tsx
'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

slider-pagination.tsx
'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: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
currentPagenumber1The currently active page.
totalPagesnumber10Total number of pages available.
onPageChange(page: number) => voidundefinedCallback function when a page is selected.
Context Worth Keeping In Orbit

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.