Compact paginations
Five dense pagination forms for tight toolbars: a jump-to-page input, first and last jumps, a record range, a bare Previous and Next pair and a page counter. Each carries its own page state, is sized, token-driven and keeps its bound arrows properly disabled.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/pagination-compactDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/pagination-compact.tsx"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Compact pagination family: 5 dense paginators. Instead of laying out number buttons they do the same job in a narrow space: a jump-to-page input, first/last jumps, a record range, a plain prev/next and a counter. Colour comes only from semantic tokens; alpha through color-mix. Motion is gated with useReducedMotion(). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const controlSize: Record<StyledSize, string> = {
sm: "h-8 text-sm",
md: "h-9 text-sm",
lg: "h-10 text-sm",
xl: "h-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"
const arrowBtn = "rounded-lg border border-border text-foreground hover:bg-accent"
interface PaginationProps {
className?: string
size?: StyledSize
total?: number
defaultPage?: number
}
/* Dahili sayfa durumu. */
function usePageState(total: number, defaultPage: number) {
const n = Math.max(total, 1)
const clamp = React.useCallback((p: number) => Math.min(Math.max(p, 1), n), [n])
const [current, setCurrent] = React.useState(() => clamp(defaultPage))
const goto = (p: number) => setCurrent(clamp(p))
return { current: clamp(current), total: n, goto }
}
/* InputPagination: a number input for jumping straight to a page. The input is typed into freely and is clamped and applied on blur or Enter. */
export function InputPagination({ className, size = "md", total = 12, defaultPage = 1 }: PaginationProps) {
const { current, total: n, goto } = usePageState(total, defaultPage)
const [draft, setDraft] = React.useState(String(current))
const inputId = React.useId()
React.useEffect(() => {
setDraft(String(current))
}, [current])
const commit = () => {
const parsed = Number.parseInt(draft, 10)
if (Number.isNaN(parsed)) {
setDraft(String(current))
return
}
goto(parsed)
setDraft(String(Math.min(Math.max(parsed, 1), n)))
}
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center gap-2", className)}
>
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(btnBase, focusRing, iconSize[size], arrowBtn)}
>
<ChevronLeft />
</button>
<label htmlFor={inputId} className="text-sm text-muted-foreground">
Page
</label>
<input
id={inputId}
type="number"
inputMode="numeric"
min={1}
max={n}
value={draft}
aria-label="Jump to page"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
commit()
}
}}
className={cn(
controlSize[size],
focusRing,
"w-14 rounded-lg border border-border bg-background px-2 text-center tabular-nums text-foreground"
)}
/>
<span className={cn(controlSize[size], "inline-flex items-center text-sm text-muted-foreground")}>of {n}</span>
<button
type="button"
aria-label="Go to next page"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(btnBase, focusRing, iconSize[size], arrowBtn)}
>
<ChevronRight />
</button>
</nav>
)
}
/* JumpPagination: prev/next yaninda ilk ve son sayfaya atlama. */
export function JumpPagination({ className, size = "md", total = 12, defaultPage = 5 }: PaginationProps) {
const { current, total: n, goto } = usePageState(total, defaultPage)
const textBtn = cn(
controlSize[size],
"rounded-lg border border-border px-3 text-foreground hover:bg-accent"
)
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center gap-1.5", className)}
>
<button
type="button"
disabled={current <= 1}
onClick={() => goto(1)}
className={cn(btnBase, focusRing, textBtn)}
>
First
</button>
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(btnBase, focusRing, iconSize[size], arrowBtn)}
>
<ChevronLeft />
</button>
<span
aria-current="page"
className={cn(
controlSize[size],
"inline-flex min-w-9 items-center justify-center rounded-lg bg-[color-mix(in_oklab,var(--color-primary)_14%,transparent)] px-2 font-medium tabular-nums text-foreground"
)}
>
{current}
</span>
<button
type="button"
aria-label="Go to next page"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(btnBase, focusRing, iconSize[size], arrowBtn)}
>
<ChevronRight />
</button>
<button
type="button"
disabled={current >= n}
onClick={() => goto(n)}
className={cn(btnBase, focusRing, textBtn)}
>
Last
</button>
</nav>
)
}
/* RangePagination: gosterilen kayit araligi ("1-10 of 120") + oklar. */
export function RangePagination({ className, size = "md", total = 12, defaultPage = 1 }: PaginationProps) {
const { current, total: n, goto } = usePageState(total, defaultPage)
const perPage = 10
const rows = n * perPage
const from = (current - 1) * perPage + 1
const to = Math.min(current * perPage, rows)
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center gap-3", className)}
>
<span className={cn(controlSize[size], "inline-flex items-center tabular-nums text-muted-foreground")}>
<span className="font-medium text-foreground">
{from}-{to}
</span>
<span className="ml-1">of {rows}</span>
</span>
<span className="inline-flex items-center gap-1">
<button
type="button"
aria-label="Go to previous page"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(btnBase, focusRing, iconSize[size], arrowBtn)}
>
<ChevronLeft />
</button>
<button
type="button"
aria-label="Go to next page"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(btnBase, focusRing, iconSize[size], arrowBtn)}
>
<ChevronRight />
</button>
</span>
</nav>
)
}
/* SimplePagination: Previous / Next text buttons only. */
export function SimplePagination({ className, size = "md", total = 12, defaultPage = 1 }: PaginationProps) {
const { current, total: n, goto } = usePageState(total, defaultPage)
const textBtn = cn(
controlSize[size],
"gap-1.5 rounded-lg border border-border px-3 text-foreground hover:bg-accent"
)
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn("inline-flex items-center gap-2", className)}
>
<button
type="button"
disabled={current <= 1}
onClick={() => goto(current - 1)}
className={cn(btnBase, focusRing, textBtn)}
>
<ChevronLeft />
Previous
</button>
<button
type="button"
disabled={current >= n}
onClick={() => goto(current + 1)}
className={cn(btnBase, focusRing, textBtn)}
>
Next
<ChevronRight />
</button>
</nav>
)
}
/* CounterPagination: a "Page 3 of 12" counter; it transitions softly when the number
changes. */
export function CounterPagination({ className, size = "md", total = 12, defaultPage = 3 }: PaginationProps) {
const reduce = useReducedMotion()
const { current, total: n, goto } = usePageState(total, defaultPage)
return (
<nav
data-slot="styled-pagination"
aria-label="Pagination"
className={cn(
"inline-flex items-center gap-1 rounded-lg border border-border bg-card p-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-md text-foreground hover:bg-accent")}
>
<ChevronLeft />
</button>
<span
aria-live="polite"
className={cn(controlSize[size], "inline-flex items-center gap-1 px-2 tabular-nums text-muted-foreground")}
>
Page
<motion.span
key={current}
initial={reduce ? false : { opacity: 0, y: -4 }}
animate={reduce ? {} : { opacity: 1, y: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.18, ease: "easeOut" }}
className="font-semibold text-foreground"
>
{current}
</motion.span>
of <span className="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-md text-foreground hover:bg-accent")}
>
<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.
Input
A labelled number input to jump straight to a page.
import { InputPagination } from "@/components/ui/pagination-compact"
<InputPagination />Jump
First and last jumps around the current page.
import { JumpPagination } from "@/components/ui/pagination-compact"
<JumpPagination />Range
The visible record range plus two arrows.
import { RangePagination } from "@/components/ui/pagination-compact"
<RangePagination />Simple
Just Previous and Next.
import { SimplePagination } from "@/components/ui/pagination-compact"
<SimplePagination />Counter
A boxed Page 3 of 12 counter.
import { CounterPagination } from "@/components/ui/pagination-compact"
<CounterPagination />ai2 Compact paginations: 5 styled variations on the token system
The ai2 Compact paginations are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around dense page navigation controls for tight toolbars. 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 fades the page counter as the number changes. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the counter updates instantly with no animation.
What is in the ai2 Compact paginations?
5 exports in one file: Input, Jump, Range, Simple and Counter. 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 fades the page counter as the number changes.
- Reduced-motion aware: Under prefers-reduced-motion, the counter updates instantly 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 Compact paginations 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.