UI/UI
Pagination
Navigation component for splitting content across multiple pages with previous/next controls and page numbers.
function PaginationBasic() {
const [currentPage, setCurrentPage] = React.useState(1);
const totalPages = 10;
const isMobile = useMediaQuery("(max-width: 640px)");
// Show fewer pages on mobile
const visiblePages = isMobile ? 3 : 5;
const pages = Array.from({ length: Math.min(visiblePages, totalPages) }, (_, i) => i + 1);
return (
<Pagination className="flex-wrap">
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
size={isMobile ? "sm" : "default"}
>
{isMobile ? "Prev" : "Previous"}
</PaginationPrevious>
{pages.map((page) => (
<PaginationItem
key={page}
isActive={page === currentPage}
onClick={() => setCurrentPage(page)}
size={isMobile ? "sm" : "default"}
>
{page}
</PaginationItem>
))}
{totalPages > visiblePages && <PaginationEllipsis />}
{totalPages > visiblePages && (
<PaginationItem
isActive={currentPage === totalPages}
onClick={() => setCurrentPage(totalPages)}
size={isMobile ? "sm" : "default"}
>
{totalPages}
</PaginationItem>
)}
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
size={isMobile ? "sm" : "default"}
>
{isMobile ? "Next" : "Next"}
</PaginationNext>
</Pagination>
);
}
Installation
Install following dependencies:
npm install class-variance-authority lucide-react
pnpm add class-variance-authority lucide-react
yarn add class-variance-authority lucide-react
bun add class-variance-authority lucide-react
Copy and paste the following code into your project.
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
const paginationVariants = cva("flex items-center justify-center", {
variants: {
variant: {
default: "gap-1",
compact: "gap-0.5",
},
},
defaultVariants: {
variant: "default",
},
});
const paginationItemVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"rounded-[var(--radius)] h-9 w-9 text-[hsl(var(--hu-foreground))] hover:bg-[hsl(var(--hu-accent))] hover:text-[hsl(var(--hu-accent-foreground))] focus-visible:ring-[hsl(var(--hu-ring))]",
outline:
"rounded-[var(--radius)] h-9 w-9 border border-[hsl(var(--hu-border))] text-[hsl(var(--hu-foreground))] hover:bg-[hsl(var(--hu-accent))] hover:text-[hsl(var(--hu-accent-foreground))] focus-visible:ring-[hsl(var(--hu-ring))]",
ghost:
"rounded-[var(--radius)] h-9 w-9 text-[hsl(var(--hu-foreground))] hover:bg-[hsl(var(--hu-accent))] hover:text-[hsl(var(--hu-accent-foreground))] focus-visible:ring-[hsl(var(--hu-ring))]",
},
size: {
default: "h-9 w-9",
sm: "h-8 w-8 text-xs",
lg: "h-10 w-10",
},
state: {
default: "",
active:
"bg-[hsl(var(--hu-primary))] text-[hsl(var(--hu-primary-foreground))] hover:bg-[hsl(var(--hu-primary))]/90",
},
},
defaultVariants: {
variant: "default",
size: "default",
state: "default",
},
},
);
const paginationNavVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 rounded-[var(--radius)] px-3 text-[hsl(var(--hu-foreground))] hover:bg-[hsl(var(--hu-accent))] hover:text-[hsl(var(--hu-accent-foreground))] focus-visible:ring-[hsl(var(--hu-ring))]",
{
variants: {
size: {
default: "h-9",
sm: "h-8 text-xs px-2",
lg: "h-10 px-4",
},
},
defaultVariants: {
size: "default",
},
},
);
export interface PaginationProps
extends React.HTMLAttributes<HTMLElement>,
VariantProps<typeof paginationVariants> {}
export interface PaginationItemProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof paginationItemVariants> {
isActive?: boolean;
}
export interface PaginationNavProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof paginationNavVariants> {}
export interface PaginationEllipsisProps
extends React.HTMLAttributes<HTMLSpanElement> {}
const Pagination = React.forwardRef<HTMLElement, PaginationProps>(
({ className, variant, ...props }, ref) => (
<nav
role="navigation"
aria-label="pagination"
className={cn(paginationVariants({ variant, className }))}
ref={ref}
{...props}
/>
),
);
Pagination.displayName = "Pagination";
const PaginationItem = React.forwardRef<HTMLButtonElement, PaginationItemProps>(
({ className, variant, size, state, isActive, ...props }, ref) => (
<button
className={cn(
paginationItemVariants({
variant,
size,
state: isActive ? "active" : state,
className,
}),
)}
ref={ref}
aria-current={isActive ? "page" : undefined}
{...props}
/>
),
);
PaginationItem.displayName = "PaginationItem";
const PaginationPrevious = React.forwardRef<
HTMLButtonElement,
PaginationNavProps
>(({ className, size, children, ...props }, ref) => (
<button
className={cn(paginationNavVariants({ size, className }))}
ref={ref}
{...props}
>
<ChevronLeft className="h-4 w-4" />
{children || "Previous"}
</button>
));
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = React.forwardRef<HTMLButtonElement, PaginationNavProps>(
({ className, size, children, ...props }, ref) => (
<button
className={cn(paginationNavVariants({ size, className }))}
ref={ref}
{...props}
>
{children || "Next"}
<ChevronRight className="h-4 w-4" />
</button>
),
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = React.forwardRef<
HTMLSpanElement,
PaginationEllipsisProps
>(({ className, ...props }, ref) => (
<span
className={cn(
"inline-flex items-center justify-center h-9 w-9 text-[hsl(var(--hu-muted-foreground))]",
className,
)}
ref={ref}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
));
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
paginationVariants,
paginationItemVariants,
paginationNavVariants,
};
npx hextaui@latest add pagination
pnpm dlx hextaui@latest add pagination
yarn dlx hextaui@latest add pagination
bun x hextaui@latest add pagination
Usage
import {
Pagination,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
} from "@/components/ui/pagination";
function PaginationExample() {
const [currentPage, setCurrentPage] = React.useState(1);
const totalPages = 10;
return (
<Pagination>
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
Previous
</PaginationPrevious>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem
key={page}
isActive={page === currentPage}
onClick={() => setCurrentPage(page)}
>
{page}
</PaginationItem>
))}
<PaginationEllipsis />
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
Next
</PaginationNext>
</Pagination>
);
}
Examples
Responsive Design
The pagination component is designed to be responsive and adapts to different screen sizes:
- Mobile (< 640px): Uses smaller buttons, fewer visible pages, and shorter labels
- Tablet (< 768px): Reduced delta for ellipsis calculation
- Desktop: Full feature set with optimal spacing
The responsive behavior is implemented using a custom useMediaQuery
hook that detects screen size changes.
Basic Pagination
// Hook to detect screen size
function useMediaQuery(query: string) {
const [matches, setMatches] = React.useState(false);
React.useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
media.addListener(listener);
return () => media.removeListener(listener);
}, [matches, query]);
return matches;
}
function PaginationBasic() {
const [currentPage, setCurrentPage] = React.useState(1);
const totalPages = 10;
const isMobile = useMediaQuery("(max-width: 640px)");
// Show fewer pages on mobile
const visiblePages = isMobile ? 3 : 5;
const pages = Array.from({ length: Math.min(visiblePages, totalPages) }, (_, i) => i + 1);
return (
<Pagination className="flex-wrap">
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
size={isMobile ? "sm" : "default"}
>
{isMobile ? "Prev" : "Previous"}
</PaginationPrevious>
{pages.map((page) => (
<PaginationItem
key={page}
isActive={page === currentPage}
onClick={() => setCurrentPage(page)}
size={isMobile ? "sm" : "default"}
>
{page}
</PaginationItem>
))}
{totalPages > visiblePages && <PaginationEllipsis />}
{totalPages > visiblePages && (
<PaginationItem
isActive={currentPage === totalPages}
onClick={() => setCurrentPage(totalPages)}
size={isMobile ? "sm" : "default"}
>
{totalPages}
</PaginationItem>
)}
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
size={isMobile ? "sm" : "default"}
>
{isMobile ? "Next" : "Next"}
</PaginationNext>
</Pagination>
);
}
With Ellipsis
function PaginationWithEllipsis() {
const [currentPage, setCurrentPage] = React.useState(5);
const totalPages = 20;
const isMobile = useMediaQuery("(max-width: 640px)");
const isTablet = useMediaQuery("(max-width: 768px)"); const getVisiblePages = () => {
// Adjust delta based on screen size
const delta = isMobile ? 1 : isTablet ? 1 : 2;
const rangeWithDots = [];
// Special case: if total pages <= 7, show all pages
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
// Always show first page
rangeWithDots.push(1);
// Calculate the range around current page
let startPage = Math.max(2, currentPage - delta);
let endPage = Math.min(totalPages - 1, currentPage + delta);
// Ensure current page is ALWAYS included in the range
if (currentPage === 1) {
// Current page is first page, extend range to the right
endPage = Math.min(totalPages - 1, 1 + (delta * 2));
} else if (currentPage === totalPages) {
// Current page is last page, extend range to the left
startPage = Math.max(2, totalPages - (delta * 2));
} else {
// Current page is in the middle, ensure it's in the range
startPage = Math.max(2, Math.min(startPage, currentPage));
endPage = Math.min(totalPages - 1, Math.max(endPage, currentPage));
}
// Add ellipsis after first page if there's a gap
if (startPage > 2) {
rangeWithDots.push("...");
}
// Add pages around current page (ensuring current page is always included)
for (let i = startPage; i <= endPage; i++) {
if (i !== 1 && i !== totalPages) {
rangeWithDots.push(i);
}
}
// Add ellipsis before last page if there's a gap
if (endPage < totalPages - 1) {
rangeWithDots.push("...");
}
// Always show last page if it's different from first
if (totalPages > 1) {
rangeWithDots.push(totalPages);
}
return rangeWithDots;
};
return (
<div className="w-full overflow-x-auto">
<Pagination className="flex-wrap min-w-fit">
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
size={isMobile ? "sm" : "default"}
>
{isMobile ? "Prev" : "Previous"}
</PaginationPrevious>
{getVisiblePages().map((page, index) =>
page === "..." ? (
<PaginationEllipsis key={`ellipsis-${index}`} />
) : (
<PaginationItem
key={page}
isActive={page === currentPage}
onClick={() => setCurrentPage(page as number)}
size={isMobile ? "sm" : "default"}
>
{page}
</PaginationItem>
),
)}
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
size={isMobile ? "sm" : "default"}
>
{isMobile ? "Next" : "Next"}
</PaginationNext>
</Pagination>
</div>
);
}
Compact
function PaginationCompact() {
const [currentPage, setCurrentPage] = React.useState(3);
const totalPages = 8;
const isMobile = useMediaQuery("(max-width: 640px)");
return (
<div className="flex items-center gap-2 sm:gap-4">
<Pagination variant="compact" className="flex-wrap">
<PaginationPrevious
size={isMobile ? "sm" : "default"}
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
{isMobile ? "‹" : "Previous"}
</PaginationPrevious>
<div className="flex items-center px-2 sm:px-3">
<span className="text-xs sm:text-sm text-[hsl(var(--hu-muted-foreground))]">
{isMobile ? `${currentPage}/${totalPages}` : `Page ${currentPage} of ${totalPages}`}
</span>
</div>
<PaginationNext
size={isMobile ? "sm" : "default"}
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
{isMobile ? "›" : "Next"}
</PaginationNext>
</Pagination>
</div>
);
}
Simple
function PaginationSimple() {
const [currentPage, setCurrentPage] = React.useState(1);
const totalPages = 5;
return (
<Pagination>
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
/>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem
key={page}
isActive={page === currentPage}
onClick={() => setCurrentPage(page)}
>
{page}
</PaginationItem>
))}
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
/>
</Pagination>
);
}
Minimal
function PaginationMinimal() {
const [currentPage, setCurrentPage] = React.useState(2);
const totalPages = 10;
return (
<Pagination>
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
← Prev
</PaginationPrevious>
<div className="flex items-center px-4">
<span className="text-sm text-[hsl(var(--hu-muted-foreground))]">
{currentPage} / {totalPages}
</span>
</div>
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
Next →
</PaginationNext>
</Pagination>
);
}
With Input
Go to:
function PaginationWithInput() {
const [currentPage, setCurrentPage] = React.useState(5);
const [inputPage, setInputPage] = React.useState("5");
const totalPages = 25;
const handleGoToPage = () => {
const page = parseInt(inputPage);
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleGoToPage();
}
};
return (
<div className="flex items-center gap-4">
<Pagination>
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
/>
<PaginationItem
onClick={() => setCurrentPage(1)}
isActive={currentPage === 1}
>
1
</PaginationItem>
{currentPage > 3 && <PaginationEllipsis />}
{currentPage > 2 && currentPage < totalPages && (
<PaginationItem isActive>
{currentPage}
</PaginationItem>
)}
{currentPage < totalPages - 2 && <PaginationEllipsis />}
<PaginationItem
onClick={() => setCurrentPage(totalPages)}
isActive={currentPage === totalPages}
>
{totalPages}
</PaginationItem>
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
/>
</Pagination>
<div className="flex items-center gap-2">
<span className="text-sm text-[hsl(var(--hu-muted-foreground))]">Go to:</span>
<input
type="number"
min="1"
max={totalPages}
value={inputPage}
onChange={(e) => setInputPage(e.target.value)}
onKeyPress={handleKeyPress}
className="w-16 h-8 px-2 text-sm border border-[hsl(var(--hu-border))] rounded-lg bg-[hsl(var(--hu-background))] text-[hsl(var(--hu-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--hu-ring))]"
/>
<button
onClick={handleGoToPage}
className="h-8 px-3 text-sm font-medium rounded-lg bg-[hsl(var(--hu-primary))] text-[hsl(var(--hu-primary-foreground))] hover:bg-[hsl(var(--hu-primary))]/90 focus:outline-none focus:ring-2 focus:ring-[hsl(var(--hu-ring))]"
>
Go
</button>
</div>
</div>
);
}
Sizes
function PaginationSizes() {
const [currentPage, setCurrentPage] = React.useState(2);
const totalPages = 5;
return (
<div className="space-y-6">
{/* Small */}
<div>
<h4 className="text-sm font-medium mb-2">Small</h4>
<Pagination>
<PaginationPrevious
size="sm"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
/>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem
key={page}
size="sm"
isActive={page === currentPage}
onClick={() => setCurrentPage(page)}
>
{page}
</PaginationItem>
))}
<PaginationNext
size="sm"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
/>
</Pagination>
</div>
{/* Default */}
<div>
<h4 className="text-sm font-medium mb-2">Default</h4>
<Pagination>
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
/>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem
key={page}
isActive={page === currentPage}
onClick={() => setCurrentPage(page)}
>
{page}
</PaginationItem>
))}
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
/>
</Pagination>
</div>
{/* Large */}
<div>
<h4 className="text-sm font-medium mb-2">Large</h4>
<Pagination>
<PaginationPrevious
size="lg"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
/>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem
key={page}
size="lg"
isActive={page === currentPage}
onClick={() => setCurrentPage(page)}
>
{page}
</PaginationItem>
))}
<PaginationNext
size="lg"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
/>
</Pagination>
</div>
</div>
);
}
Outlined
function PaginationOutlined() {
const [currentPage, setCurrentPage] = React.useState(2);
const totalPages = 6;
return (
<Pagination>
<PaginationPrevious
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
/>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<PaginationItem
key={page}
variant="outline"
isActive={page === currentPage}
onClick={() => setCurrentPage(page)}
>
{page}
</PaginationItem>
))}
<PaginationNext
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
/>
</Pagination>
);
}
API Reference
useMediaQuery Hook
A utility hook for responsive design that detects screen size changes:
function useMediaQuery(query: string): boolean;
Parameters:
query
: A CSS media query string (e.g.,"(max-width: 640px)"
)
Returns:
boolean
: Whether the media query matches
Pagination
Prop | Type | Default |
---|---|---|
className? | string | undefined |
variant? | "default" | "compact" | "default" |
PaginationItem
Prop | Type | Default |
---|---|---|
disabled? | boolean | false |
onClick? | () => void | undefined |
isActive? | boolean | false |
size? | "default" | "sm" | "lg" | "default" |
variant? | "default" | "outline" | "ghost" | "default" |
PaginationPrevious / PaginationNext
Prop | Type | Default |
---|---|---|
children? | React.ReactNode | "Previous" / "Next" |
disabled? | boolean | false |
onClick? | () => void | undefined |
size? | "default" | "sm" | "lg" | "default" |
PaginationEllipsis
Prop | Type | Default |
---|---|---|
className? | string | undefined |
Edit on GitHub
Last updated on