Velocity UI
Loading…
Menu

Pagination Stack

A collection of 7 premium pagination variants featuring spring indicators, neon glows, and liquid blur effects.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add pagination-stack

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'
import PaginationScroll from './scroll-pagination'
import PaginationSlider from './slider-pagination'
import PaginationAurora from './aurora-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' },
    { name: 'Aurora', Component: PaginationAurora, cmd: 'npx vui-registry-cli-v1 add aurora-pagination' },
    { name: 'Scroll', Component: PaginationScroll, cmd: 'npx vui-registry-cli-v1 add scroll-pagination' },
    { name: 'Slider', Component: PaginationSlider, cmd: 'npx vui-registry-cli-v1 add slider-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 ${v.name === 'Slider' ? 'md:col-span-2' : ''}`}>
          <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

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 w-[${w}px] 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'
              }`}
            >
              <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'

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">
          {pages.map((p, i) => (
            <button
              key={p}
              onClick={() => setPage(p)}
              className="relative w-10 h-10 grid place-items-center rounded-xl z-10"
            >
              <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>
          ))}
          <motion.div
            layout
            className="absolute top-0 bottom-0 rounded-xl bg-primary/10 border border-primary/20 shadow-[0_0_20px_rgba(var(--primary),0.2)]"
            style={{ width: 40, left: (page - 1) * 44 }}
            transition={{ type: 'spring', stiffness: 350, damping: 25 }}
          />
        </div>
      </div>
    </div>
  )
}

Source Code

liquid-pagination.tsx
'use client'

import { useState } from 'react'
import { motion } from 'framer-motion'

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="relative flex items-center gap-2 px-3 py-2 rounded-2xl bg-white dark:bg-neutral-900 border border-black/5 dark:border-white/10 shadow-sm overflow-hidden">
        <motion.div
          layout
          className="absolute 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, left: (page - 1) * 44 - 6, top: -6 }}
          transition={{ type: 'spring', stiffness: 200, damping: 25 }}
        />
        {pages.map((p) => (
          <button
            key={p}
            onClick={() => setPage(p)}
            className={`relative w-9 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 bg-blue-50/50 dark:bg-blue-900/20' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-300'
            }`}
          >
            <span className="text-sm">{p}</span>
          </button>
        ))}
      </div>
    </div>
  )
}

Source Code

neon-pagination.tsx
'use client'

import { useState } from 'react'
import { motion } from 'framer-motion'

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="relative flex items-center gap-2 px-3 py-3 rounded-2xl bg-neutral-900/80 border border-neutral-800 shadow-2xl shadow-black/50 backdrop-blur-sm">
        {pages.map((p) => (
          <button
            key={p}
            onClick={() => setPage(p)}
            className={`relative w-10 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'
            }`}
          >
            <span className="relative z-10">{p}</span>
            {page === p && (
              <motion.div
                layoutId="neon-glow"
                className="absolute inset-0 rounded-xl bg-indigo-500/20 border border-indigo-500/50"
                style={{ boxShadow: '0 0 20px 2px rgba(99,102,241,0.4), inset 0 0 10px rgba(99,102,241,0.2)' }}
                transition={{ type: 'spring', stiffness: 300, damping: 25 }}
              />
            )}
          </button>
        ))}
      </div>
    </div>
  )
}

Source Code

scroll-pagination.tsx
'use client'

import { useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { ChevronLeft, ChevronRight } from 'lucide-react'

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 go(dir: -1 | 1) {
    const next = Math.min(total, Math.max(1, page + dir))
    setPage(next)
    const el = scroller.current
    if (el) el.scrollTo({ left: (next - 1) * 48, behavior: 'smooth' })
  }
  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]"
          >
            {pages.map((p) => (
              <button
                key={p}
                onClick={() => setPage(p)}
                className={`w-10 h-10 flex-shrink-0 grid place-items-center rounded-xl snap-center transition-all duration-300 ${
                  page === p 
                    ? 'bg-primary text-primary-foreground font-bold shadow-md scale-100' 
                    : 'text-muted-foreground hover:bg-foreground/5 scale-90 opacity-70 hover:opacity-100'
                }`}
              >
                {p}
              </button>
            ))}
          </div>
          <motion.div
            className="absolute top-1/2 -translate-y-1/2 rounded-xl bg-primary/10 border border-primary/20 pointer-events-none"
            style={{ width: 44, height: 44, left: (page - 1) * 48 + 4 }}
            animate={{ left: (page - 1) * 48 + 4 }}
            transition={{ type: 'spring', stiffness: 350, damping: 25 }}
          />
          <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 { useState } from 'react'
import { motion } from 'framer-motion'

export default function PaginationSlider() {
  const [page, setPage] = useState(1)
  const total = 10
  const pages = Array.from({ length: total }, (_, i) => i + 1)
  const progress = (page - 1) / (total - 1)
  return (
    <div className="flex flex-col items-center justify-center p-8 gap-6 w-full max-w-md mx-auto">
      <div className="relative w-full group">
        <div className="absolute top-1/2 left-0 right-0 h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-full -translate-y-1/2" />
        <motion.div
          className="absolute top-1/2 left-0 h-1.5 bg-primary rounded-full -translate-y-1/2 origin-left"
          initial={{ scaleX: progress }}
          animate={{ scaleX: progress }}
          transition={{ type: 'spring', stiffness: 300, damping: 30 }}
          style={{ width: '100%' }}
        />
        <input
          type="range"
          min={1}
          max={total}
          value={page}
          onChange={(e) => setPage(parseInt(e.target.value))}
          className="relative w-full h-8 opacity-0 cursor-pointer z-10"
        />
        <motion.div
          className="absolute top-1/2 w-6 h-6 bg-white dark:bg-neutral-900 border-2 border-primary rounded-full shadow-lg -translate-y-1/2 -translate-x-1/2 pointer-events-none"
          animate={{ left: `${progress * 100}%`, scale: 1 }}
          whileHover={{ scale: 1.08 }}
          whileTap={{ scale: 0.95 }}
          transition={{ type: 'spring', stiffness: 350, damping: 25 }}
        />
        <motion.div
          className="absolute top-1/2 w-12 h-12 rounded-full -translate-y-1/2 -translate-x-1/2 pointer-events-none"
          animate={{ left: `${progress * 100}%`, opacity: 0.5 }}
          transition={{ type: 'spring', stiffness: 300, damping: 30 }}
          style={{ boxShadow: '0 0 30px 6px rgba(var(--primary), 0.25)' }}
        />
      </div>
      <div className="flex items-center justify-between w-full px-1">
        {pages.map((p) => (
          <button
            key={p}
            onClick={() => setPage(p)}
            className={`w-8 h-8 grid place-items-center rounded-lg text-xs font-medium transition-all duration-200 ${
              page === p 
                ? 'bg-primary text-primary-foreground shadow-md -translate-y-1 scale-110' 
                : 'text-muted-foreground hover:bg-neutral-100 dark:hover:bg-neutral-800'
            }`}
          >
            {p}
          </button>
        ))}
      </div>
    </div>
  )
}

Source Code

aurora-pagination.tsx
 'use client'
 
 import { useState } from 'react'
 import { motion } from 'framer-motion'
 
 export default function PaginationAurora() {
   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 flex items-center gap-2 px-3 py-2 rounded-2xl border border-border bg-background/50 backdrop-blur-md">
         <motion.div
           layout
           className="absolute rounded-2xl"
           style={{ width: w, height: 44, left: (page - 1) * (w + 4) + 2 }}
           transition={{ type: 'spring', stiffness: 320, damping: 26 }}
         >
           <motion.div
             className="absolute inset-0 rounded-xl"
             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-xl bg-primary/10 border border-primary/20"
           />
         </motion.div>
         {pages.map((p) => (
           <button
             key={p}
             onClick={() => setPage(p)}
             className={`relative w-11 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'
             }`}
           >
             <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>
   )
 }

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.