Styled pagination
Five pagination treatments: numbered, arrows, dots, pill and minimal. Each is sized, token-driven and keyboard accessible with aria-current on the active page.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/pagination-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/pagination-styled.tsx"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Pagination family: 5 decorative paginators. Color comes ONLY from semantic tokens;
the active page is a primary token, via alpha color-mix (never var(--x)/0.4).
Controlled (page/ onPageChange) or uncontrolled (defaultPage). The buttons are real
<button>s with a focus-visible ring. Gated on framer-motion's useReducedMotion. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const controlSize: Record<StyledSize, string> = {
sm: "h-8 min-w-8 text-sm",
md: "h-9 min-w-9 text-sm",
lg: "h-10 min-w-10 text-sm",
xl: "h-12 min-w-12 text-base",
}
const iconSize: Record<StyledSize, string> = {
sm: "size-8",
md: "size-9",
lg: "size-10",
xl: "size-12",
}
const focusRing =
"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
const btnBase =
"relative inline-flex shrink-0 select-none items-center justify-center font-medium whitespace-nowrap transition-colors [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
interface PaginationProps {
className?: string
size?: StyledSize
total?: number
page?: number
defaultPage?: number
onPageChange?: (p: number) => void
}
/* page state: kontrollu (page verilirse) yoksa kontrolsuz (defaultPage). */
function usePagination(props: PaginationProps) {
const { total = 8, page, defaultPage = 1, onPageChange } = props
const clamp = React.useCallback(
(p: number) => Math.min(Math.max(p, 1), Math.max(total, 1)),
[total]
)
const [internal, setInternal] = React.useState(() => clamp(defaultPage))
const current = clamp(page ?? internal)
const goto = (p: number) => {
const next = clamp(p)
if (next === current) return
if (page === undefined) setInternal(next)
onPageChange?.(next)
}
return { current, total: Math.max(total, 1), goto }
}
/* 1 ... k-1 k k+1 ... N sekilli, ortada current komsulariyla ellipsis'li dizi. */
function pageRange(current: number, total: number): (number | "ellipsis")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
const out: (number | "ellipsis")[] = [1]
const left = Math.max(2, current - 1)
const right = Math.min(total - 1, current + 1)
if (left > 2) out.push("ellipsis")
for (let p = left; p <= right; p++) out.push(p)
if (right < total - 1) out.push("ellipsis")
out.push(total)
return out
}
/* NumberedPagination: yuvarlak sayi butonlari, aktif primary dolgulu, framer
layoutId ile kayan vurgu. */
export function NumberedPagination({
className,
size = "md",
total = 8,
page,
defaultPage = 1,
onPageChange,
}: PaginationProps) {
const reduce = useReducedMotion()
const { current, total: n, goto } = usePagination({ total, page, defaultPage, onPageChange })
const items = pageRange(current, n)
const highlightId = React.useId()
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("flex items-center gap-1", className)}
>
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(btnBase, focusRing, iconSize[size], "rounded-full text-foreground hover:bg-accent")}
>
<ChevronLeft />
</button>
{items.map((it, i) =>
it === "ellipsis" ? (
<span
key={`e${i}`}
aria-hidden="true"
className={cn(iconSize[size], "inline-flex items-center justify-center text-muted-foreground")}
>
<MoreHorizontal />
</span>
) : (
<button
key={it}
type="button"
aria-label={`Go to page ${it}`}
aria-current={it === current ? "page" : undefined}
onClick={() => goto(it)}
className={cn(
btnBase,
focusRing,
iconSize[size],
"rounded-full",
it === current ? "text-primary-foreground" : "text-foreground hover:bg-accent"
)}
>
{it === current && (
<motion.span
layoutId={reduce ? undefined : `${highlightId}-active`}
aria-hidden="true"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }}
className="absolute inset-0 rounded-full bg-primary"
/>
)}
<span className="relative">{it}</span>
</button>
)
)}
<button
type="button"
aria-label="Go to next page"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(btnBase, focusRing, iconSize[size], "rounded-full text-foreground hover:bg-accent")}
>
<ChevronRight />
</button>
</nav>
)
}
/* ArrowsPagination: kompakt prev / "Page X of N" / next. */
export function ArrowsPagination({
className,
size = "md",
total = 8,
page,
defaultPage = 1,
onPageChange,
}: PaginationProps) {
const { current, total: n, goto } = usePagination({ total, page, defaultPage, onPageChange })
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center", className)}
>
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(
btnBase,
focusRing,
iconSize[size],
"rounded-lg border border-border text-foreground hover:bg-accent"
)}
>
<ChevronLeft />
</button>
<span className={cn(controlSize[size], "inline-flex items-center px-2 tabular-nums text-muted-foreground")}>
Page <span className="mx-1 font-medium text-foreground">{current}</span> of{" "}
<span className="ml-1 font-medium text-foreground">{n}</span>
</span>
<button
type="button"
aria-label="Go to next page"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(
btnBase,
focusRing,
iconSize[size],
"rounded-lg border border-border text-foreground hover:bg-accent"
)}
>
<ChevronRight />
</button>
</nav>
)
}
/* DotsPagination: dots; the active dot widens and turns primary (suits a carousel). */
export function DotsPagination({
className,
size = "md",
total = 8,
page,
defaultPage = 1,
onPageChange,
}: PaginationProps) {
const reduce = useReducedMotion()
const { current, total: n, goto } = usePagination({ total, page, defaultPage, onPageChange })
const dot: Record<StyledSize, string> = {
sm: "h-1.5",
md: "h-2",
lg: "h-2.5",
xl: "h-3",
}
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center gap-2", className)}
>
{Array.from({ length: n }, (_, i) => i + 1).map((it) => {
const active = it === current
return (
<button
key={it}
type="button"
aria-label={`Go to page ${it}`}
aria-current={active ? "page" : undefined}
onClick={() => goto(it)}
className={cn(
"relative inline-flex h-6 min-w-6 shrink-0 items-center justify-center rounded-full",
focusRing
)}
>
<motion.span
aria-hidden="true"
layout={!reduce}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }}
className={cn(
dot[size],
"block rounded-full transition-colors",
active
? "w-6 bg-primary"
: "w-2 bg-[color-mix(in_oklch,var(--color-foreground)_25%,transparent)] hover:bg-[color-mix(in_oklch,var(--color-foreground)_45%,transparent)]"
)}
/>
</button>
)
})}
</nav>
)
}
/* PillPagination: pill kabinin icinde sayilar; aktif olan ic pill. */
export function PillPagination({
className,
size = "md",
total = 8,
page,
defaultPage = 1,
onPageChange,
}: PaginationProps) {
const reduce = useReducedMotion()
const { current, total: n, goto } = usePagination({ total, page, defaultPage, onPageChange })
const items = pageRange(current, n)
const groupId = React.useId()
const pad: Record<StyledSize, string> = {
sm: "p-1",
md: "p-1",
lg: "p-1.5",
xl: "p-1.5",
}
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center rounded-full border border-border bg-accent", pad[size], className)}
>
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(btnBase, focusRing, iconSize[size], "rounded-full text-foreground hover:bg-background")}
>
<ChevronLeft />
</button>
{items.map((it, i) =>
it === "ellipsis" ? (
<span
key={`e${i}`}
aria-hidden="true"
className={cn(iconSize[size], "inline-flex items-center justify-center text-muted-foreground")}
>
<MoreHorizontal />
</span>
) : (
<button
key={it}
type="button"
aria-label={`Go to page ${it}`}
aria-current={it === current ? "page" : undefined}
onClick={() => goto(it)}
className={cn(
btnBase,
focusRing,
iconSize[size],
"rounded-full",
it === current ? "text-primary-foreground" : "text-foreground hover:bg-background"
)}
>
{it === current && (
<motion.span
layoutId={reduce ? undefined : `${groupId}-pill`}
aria-hidden="true"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }}
className="absolute inset-0 rounded-full bg-primary"
/>
)}
<span className="relative">{it}</span>
</button>
)
)}
<button
type="button"
aria-label="Go to next page"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(btnBase, focusRing, iconSize[size], "rounded-full text-foreground hover:bg-background")}
>
<ChevronRight />
</button>
</nav>
)
}
/* MinimalPagination: no frame, only prev/next chevrons + the current number. */
export function MinimalPagination({
className,
size = "md",
total = 8,
page,
defaultPage = 1,
onPageChange,
}: PaginationProps) {
const { current, total: n, goto } = usePagination({ total, page, defaultPage, onPageChange })
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center gap-3", className)}
>
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(btnBase, focusRing, iconSize[size], "rounded-md text-muted-foreground hover:text-foreground")}
>
<ChevronLeft />
</button>
<span className={cn(controlSize[size], "inline-flex items-center tabular-nums text-foreground")}>
<span className="font-medium">{current}</span>
<span className="mx-1 text-muted-foreground">/</span>
<span className="text-muted-foreground">{n}</span>
</span>
<button
type="button"
aria-label="Go to next page"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(btnBase, focusRing, iconSize[size], "rounded-md text-muted-foreground hover:text-foreground")}
>
<ChevronRight />
</button>
</nav>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Numbered
Number buttons with a token-filled active page.
import { NumberedPagination } from "@/components/ui/pagination-styled"
<NumberedPagination />Arrows
Prev, page X of N, next.
import { ArrowsPagination } from "@/components/ui/pagination-styled"
<ArrowsPagination />Dots
Dots with a wider token active dot.
import { DotsPagination } from "@/components/ui/pagination-styled"
<DotsPagination />Pill
A pill container with an inner active pill.
import { PillPagination } from "@/components/ui/pagination-styled"
<PillPagination />Minimal
Borderless chevrons with the current number.
import { MinimalPagination } from "@/components/ui/pagination-styled"
<MinimalPagination />ai2 Styled pagination: 5 styled variations on the token system
The ai2 Styled pagination are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around page navigation controls. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion slides the active highlight between pages. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the highlight jumps to the active page with no animation.
What is in the ai2 Styled pagination?
5 exports in one file: Numbered, Arrows, Dots, Pill and Minimal. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion slides the active highlight between pages.
- Reduced-motion aware: Under prefers-reduced-motion, the highlight jumps to the active page with no animation.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Styled pagination sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.