Page navigation that keeps every button in place, glides to the current page and jumps to any page from the ellipsis.
Showing 1–10 of 248 results
"use client"
import * as React from "react"
import { Pagination } from "@/components/ui/pagination"
const perPage = 10
const total = 248
export function PaginationDemo() {
const [page, setPage] = React.useState(1)
const first = (page - 1) * perPage + 1
const last = Math.min(page * perPage, total)
return (
<div className="flex w-full flex-col items-center gap-4">
<Pagination
page={page}
count={Math.ceil(total / perPage)}
onPageChange={setPage}
/>
<p className="text-sm text-muted-foreground tabular-nums">
Showing {first}–{last} of {total} results
</p>
</div>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/pagination.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
components/ui/pagination.tsx "use client" import * as React from "react" import { mergeProps } from "@base-ui/react/merge-props" import { useRender } from "@base-ui/react/use-render" import { IconChevronLeft, IconChevronRight, IconDots, } from "@tabler/icons-react" import { cn } from "cn" import { buttonVariants } from "@/components/ui/button" import { usePagination, type PaginationItemData, type UsePaginationOptions, } from "@/hooks/use-pagination" import { prefersReducedMotion, useSlidingHighlight } from "@/lib/motion" function clampInt(value: unknown, fallback: number, min: number, max: number) { const number = Math.floor(Number(value)) if (!Number.isFinite(number)) { return fallback } return Math.min(max, Math.max(min, number)) } const linkClassName = "h-9 min-w-[max(--spacing(9),calc(var(--pagination-digits,1)*1ch+--spacing(4)))] px-2 tabular-nums pointer-coarse:h-11 pointer-coarse:min-w-11 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-[current=page]:text-foreground aria-[current=page]:inset-ring-border aria-[current=page]:bg-background dark:aria-[current=page]:bg-input/30 dark:aria-[current=page]:inset-ring-input group-has-[>[data-slot=pagination-indicator][data-visible]]/pagination:aria-[current=page]:bg-transparent group-has-[>[data-slot=pagination-indicator][data-visible]]/pagination:aria-[current=page]:inset-ring-transparent group-has-[>[data-slot=pagination-indicator][data-visible]]/pagination:aria-[current=page]:hover:bg-transparent" type PaginationProps = React.ComponentProps<"nav"> & { page?: number defaultPage?: number count?: number onPageChange?: (page: number) => void siblings?: number boundaries?: number getPageHref?: (page: number) => string compact?: boolean | "auto" jump?: boolean } const indicatorClassName = "pointer-events-none absolute top-0 -z-1 rounded-md bg-background opacity-0 inset-ring-(length:--hairline) forced-colors:border inset-ring-border transition-[transform,width,height,opacity] duration-300 ease-out-quint data-instant:transition-opacity data-visible:opacity-100 motion-reduce:transition-opacity dark:bg-input/30 dark:inset-ring-input" const linkSelector = "[data-slot=pagination-link][aria-current=page]" function focusableItems(nav: HTMLElement) { return Array.from( nav.querySelectorAll<HTMLElement>("a[href], a[role=link], button") ).filter( (item) => item.getClientRects().length > 0 && item.getAttribute("aria-disabled") !== "true" && !item.hasAttribute("disabled") && getComputedStyle(item).visibility !== "hidden" ) } function handleArrowKeys(event: React.KeyboardEvent<HTMLElement>) { const keys = ["ArrowLeft", "ArrowRight", "Home", "End"] const target = event.target as HTMLElement if ( event.defaultPrevented || !keys.includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || target.closest("input, textarea, [contenteditable=true]") ) { return } const items = focusableItems(event.currentTarget) const index = items.indexOf(target) if (index === -1) { return } const rtl = getComputedStyle(event.currentTarget).direction === "rtl" const forward = event.key === "ArrowRight" ? !rtl : rtl const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : Math.min(items.length - 1, Math.max(0, index + (forward ? 1 : -1))) event.preventDefault() items[next]?.focus() } function Pagination({ className, children, page, defaultPage = 1, count, onPageChange, siblings, boundaries, getPageHref, compact = "auto", jump = true, style, onKeyDown, ref, ...props }: PaginationProps) { const auto = count !== undefined const navRef = React.useRef<HTMLElement | null>(null) const indicatorRef = React.useRef<HTMLSpanElement | null>(null) useSlidingHighlight(navRef, indicatorRef, linkSelector, "aria-current") const setRef = React.useCallback( (node: HTMLElement | null) => { navRef.current = node if (typeof ref === "function") { return ref(node) } if (ref) { ref.current = node } return undefined }, [ref] ) return ( <nav ref={setRef} aria-label="Pagination" data-slot="pagination" className={cn( "group/pagination relative isolate mx-auto flex w-full min-w-0 justify-center text-sm", className )} style={style} onKeyDown={(event) => { onKeyDown?.(event) handleArrowKeys(event) }} {...props} > {auto ? ( <PaginationAuto page={page} defaultPage={defaultPage} count={count} onPageChange={onPageChange} siblings={siblings} boundaries={boundaries} getPageHref={getPageHref} compact={compact} jump={jump} /> ) : ( <> <span ref={indicatorRef} aria-hidden="true" data-slot="pagination-indicator" className={indicatorClassName} /> {children} </> )} </nav> ) } function isPlainClick(event: React.MouseEvent) { return ( event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey ) } function measureList(list: HTMLElement) { const layouts = list.closest<HTMLElement>("[data-slot=pagination-layouts]") if (!layouts) { return 0 } const clone = list.cloneNode(true) as HTMLElement clone.removeAttribute("id") clone.setAttribute("aria-hidden", "true") clone.inert = true clone.className = cn(list.className, "invisible fixed top-0 left-0 flex") const digits = getComputedStyle(layouts).getPropertyValue( "--pagination-digits" ) clone.style.setProperty("--pagination-digits", digits) clone.style.fontSize = getComputedStyle(list).fontSize document.body.appendChild(clone) const width = clone.getBoundingClientRect().width clone.remove() return width } function PaginationAuto({ page: pageProp, defaultPage, count, onPageChange, siblings, boundaries, getPageHref, compact, jump, }: Required<Pick<PaginationProps, "defaultPage" | "count" | "compact">> & Pick< PaginationProps, "page" | "onPageChange" | "siblings" | "boundaries" | "getPageHref" | "jump" >) { const [internalPage, setInternalPage] = React.useState(defaultPage) const pagination = usePagination({ page: pageProp ?? internalPage, count, siblings, boundaries, }) const fullRef = React.useRef<HTMLDivElement | null>(null) const indicatorRef = React.useRef<HTMLSpanElement | null>(null) const listRef = React.useRef<HTMLUListElement | null>(null) const layoutsRef = React.useRef<HTMLDivElement | null>(null) const slots = Math.min( pagination.count, clampInt(boundaries ?? 1, 1, 0, 10) * 2 + clampInt(siblings ?? 1, 1, 0, 10) * 2 + 3 ) const digits = String(pagination.count).length const vars = { "--pagination-digits": digits, "--pagination-needed": `calc(${slots} * max(2.25rem, ${digits}ch + 1rem) + ${slots + 1} * 0.25rem + 10.5rem)`, } as React.CSSProperties useSlidingHighlight(fullRef, indicatorRef, linkSelector, "aria-current") React.useLayoutEffect(() => { const list = listRef.current const layouts = layoutsRef.current if (compact !== "auto" || !list || !layouts) { return } const update = () => { const width = measureList(list) if (width > 0) { layouts.style.setProperty( "--pagination-needed", `${Math.ceil(width)}px` ) } } update() document.fonts?.addEventListener("loadingdone", update) return () => document.fonts?.removeEventListener("loadingdone", update) }) const go = (next: number) => { const target = clampInt(next, 1, 1, Math.max(1, pagination.count)) if (target === pagination.page) { return } if (pageProp === undefined) { setInternalPage(target) } onPageChange?.(target) } const canJump = jump !== false && (!!onPageChange || !getPageHref) const jumpTo = (target: number) => { if (!onPageChange && getPageHref) { window.location.assign(getPageHref(target)) return } go(target) } const linkProps = (target: number, disabled = false) => { const href = getPageHref?.(target) return { disabled, href, render: href ? undefined : <button type="button" />, onClick: (event: React.MouseEvent) => { if (href && !isPlainClick(event)) { return } go(target) }, } } const previous = (iconOnly: boolean) => ( <PaginationItem> <PaginationPrevious {...linkProps(pagination.page - 1, !pagination.hasPrevious)} text={iconOnly ? null : undefined} /> </PaginationItem> ) const next = (iconOnly: boolean) => ( <PaginationItem> <PaginationNext {...linkProps(pagination.page + 1, !pagination.hasNext)} text={iconOnly ? null : undefined} /> </PaginationItem> ) const label = ( <> Page {pagination.page} of {pagination.count} </> ) const compactList = ( <PaginationContent className={cn( compact === "auto" && "@max-[4px]/pagination-compact:hidden" )} > {previous(true)} <PaginationItem> {canJump && pagination.count > 1 ? ( <PaginationJump count={pagination.count} onJump={jumpTo} suffix={`of ${pagination.count}`} aria-label={`Page ${pagination.page} of ${pagination.count}, go to page`} className="px-2.5" > {label} </PaginationJump> ) : ( <span data-slot="pagination-status" className="flex h-9 items-center px-2 text-sm whitespace-nowrap tabular-nums pointer-coarse:h-11" > {label} </span> )} </PaginationItem> {next(true)} </PaginationContent> ) const fullList = ( <PaginationContent ref={listRef} className={cn( compact === "auto" && "w-max @max-[4px]/pagination-full:hidden" )} > {previous(false)} {pagination.items.map((item) => item.type === "page" ? ( <PaginationItem key={item.page}> <PaginationLink {...linkProps(item.page)} isActive={item.page === pagination.page} aria-label={`Page ${item.page}`} > {item.page} </PaginationLink> </PaginationItem> ) : ( <PaginationItem key={item.position}> <PaginationEllipsis count={pagination.count} onJump={canJump ? jumpTo : undefined} /> </PaginationItem> ) )} {next(false)} </PaginationContent> ) const indicator = ( <span ref={indicatorRef} aria-hidden="true" data-slot="pagination-indicator" className={indicatorClassName} /> ) if (compact !== "auto") { return ( <div ref={layoutsRef} data-slot="pagination-layouts" style={vars} className="flex w-full justify-center" > {compact ? ( compactList ) : ( <div ref={fullRef} data-slot="pagination-full" className="group/pagination relative isolate" > {indicator} {fullList} </div> )} </div> ) } return ( <div ref={layoutsRef} data-slot="pagination-layouts" style={vars} className="grid w-full grid-cols-1" > <div ref={fullRef} data-slot="pagination-full" className="group/pagination @container/pagination-full relative isolate col-start-1 row-start-1 flex w-full max-w-[max(0px,calc((100%-var(--pagination-needed)+1px)*9999))] justify-center justify-self-center" > {indicator} {fullList} </div> <div data-slot="pagination-compact" className="@container/pagination-compact col-start-1 row-start-1 flex w-full max-w-[max(0px,calc((var(--pagination-needed)-100%)*9999))] justify-center justify-self-center" > {compactList} </div> </div> ) } function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) { return ( <ul data-slot="pagination-content" className={cn("flex items-center gap-1", className)} {...props} /> ) } function PaginationItem(props: React.ComponentProps<"li">) { return <li data-slot="pagination-item" {...props} /> } type PaginationLinkProps = useRender.ComponentProps<"a"> & { isActive?: boolean disabled?: boolean } function PaginationLink({ className, isActive = false, disabled = false, render, href, onClick, ...props }: PaginationLinkProps) { return useRender({ defaultTagName: "a", render, props: mergeProps<"a">( { className: cn( buttonVariants({ variant: "ghost" }), linkClassName, className ), href: disabled ? undefined : href, ...(disabled && !render ? { role: "link", tabIndex: 0 } : {}), }, props, { "data-slot": "pagination-link", "aria-current": isActive ? "page" : undefined, "aria-disabled": disabled || undefined, "data-active": isActive ? "" : undefined, onClick: (event: React.MouseEvent<HTMLAnchorElement>) => { if (disabled) { event.preventDefault() return } onClick?.(event) }, } as React.ComponentProps<"a"> ), }) } type PaginationStepProps = PaginationLinkProps & { text?: React.ReactNode } function PaginationPrevious({ text = "Previous", className, ...props }: PaginationStepProps) { return ( <PaginationLink aria-label="Go to previous page" className={cn(text ? "ps-2" : "px-0", className)} {...props} > <IconChevronLeft data-icon={text ? "inline-start" : undefined} className="rtl:-scale-x-100" /> {text ? <span className="max-sm:hidden">{text}</span> : null} </PaginationLink> ) } function PaginationNext({ text = "Next", className, ...props }: PaginationStepProps) { return ( <PaginationLink aria-label="Go to next page" className={cn(text ? "pe-2" : "px-0", className)} {...props} > {text ? <span className="max-sm:hidden">{text}</span> : null} <IconChevronRight data-icon={text ? "inline-end" : undefined} className="rtl:-scale-x-100" /> </PaginationLink> ) } type PaginationJumpProps = Omit<React.ComponentProps<"button">, "onClick"> & { count: number onJump: (page: number) => void suffix?: React.ReactNode } function PaginationJump({ count, onJump, suffix, className, children, ...props }: PaginationJumpProps) { const [editing, setEditing] = React.useState(false) const [value, setValue] = React.useState("") const [invalid, setInvalid] = React.useState(false) const [width, setWidth] = React.useState(0) const triggerRef = React.useRef<HTMLButtonElement | null>(null) const inputRef = React.useRef<HTMLInputElement | null>(null) const returnFocus = React.useRef(false) const digits = String(count).length React.useLayoutEffect(() => { if (editing) { inputRef.current?.focus() } else if (returnFocus.current) { returnFocus.current = false triggerRef.current?.focus() } }, [editing]) const shake = () => { const input = inputRef.current setInvalid(true) if (!input || prefersReducedMotion()) { return } input.removeAttribute("data-shake") void input.offsetWidth input.setAttribute("data-shake", "") } const commit = () => { const text = value .trim() .replace(/[\u0660-\u0669\u06f0-\u06f9\uff10-\uff19]/g, (digit) => String(digit.charCodeAt(0) & 0xf) ) const target = Number(text) if (!/^\d+$/.test(text) || target < 1 || target > count) { shake() return } const nav = inputRef.current?.closest("[data-slot=pagination]") setEditing(false) setValue("") setInvalid(false) onJump(target) requestAnimationFrame(() => { const focusTarget = nav?.querySelector<HTMLElement>(linkSelector) ?? nav?.querySelector<HTMLElement>("[data-slot=pagination-jump]") focusTarget?.focus() }) } if (editing) { return ( <span data-slot="pagination-jump-field" style={ { "--pagination-jump-width": `${width}px` } as React.CSSProperties } className="flex h-9 min-w-(--pagination-jump-width) items-center justify-center gap-1.5 text-sm whitespace-nowrap tabular-nums pointer-coarse:h-11" > <input ref={inputRef} type="text" inputMode="numeric" enterKeyHint="go" autoComplete="off" placeholder="…" aria-label={`Go to page, 1 to ${count}`} aria-invalid={invalid || undefined} value={value} style={{ "--pagination-digits": digits } as React.CSSProperties} className="h-full w-[max(--spacing(9),calc(var(--pagination-digits)*1ch+--spacing(4)))] rounded-md bg-background text-center text-sm tabular-nums ring-3 inset-ring-(length:--hairline) ring-focus-ring inset-ring-ring outline-none placeholder:text-muted-foreground focus-visible:outline-hidden aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:animate-in motion-safe:animation-duration-150 motion-safe:fade-in-0 motion-safe:zoom-in-95 data-shake:motion-safe:animate-button-shake dark:bg-input/30 forced-colors:border pointer-coarse:text-[max(16px,1rem)]" onChange={(event) => { setValue(event.target.value) setInvalid(false) }} onAnimationEnd={(event) => { event.currentTarget.removeAttribute("data-shake") }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault() commit() } else if (event.key === "Escape") { event.preventDefault() event.stopPropagation() returnFocus.current = true setEditing(false) setValue("") setInvalid(false) } }} onBlur={() => { setEditing(false) setValue("") setInvalid(false) }} /> {suffix ? ( <span className="text-muted-foreground">{suffix}</span> ) : null} </span> ) } return ( <button ref={triggerRef} type="button" data-slot="pagination-jump" className={cn( buttonVariants({ variant: "ghost" }), "h-9 min-w-[max(--spacing(9),calc(var(--pagination-digits,1)*1ch+--spacing(4)))] px-0 tabular-nums pointer-coarse:h-11 pointer-coarse:min-w-11", className )} onClick={(event) => { setWidth(event.currentTarget.getBoundingClientRect().width) setEditing(true) }} {...props} > {children} </button> ) } type PaginationEllipsisProps = React.ComponentProps<"span"> & { count?: number onJump?: (page: number) => void } function PaginationEllipsis({ className, count, onJump, ...props }: PaginationEllipsisProps) { if (onJump && count && count > 0) { return ( <PaginationJump count={count} onJump={onJump} aria-label="More pages, go to page" className="text-muted-foreground hover:text-foreground" > <IconDots /> </PaginationJump> ) } return ( <span data-slot="pagination-ellipsis" className={cn( "flex h-9 min-w-[max(--spacing(9),calc(var(--pagination-digits,1)*1ch+--spacing(4)))] items-center justify-center text-muted-foreground [&_svg:not([class*='size-'])]:size-4", className )} {...props} > <IconDots aria-hidden="true" /> <span className="sr-only">More pages</span> </span> ) } export { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, usePagination, } export type { PaginationEllipsisProps, PaginationItemData, PaginationLinkProps, PaginationProps, UsePaginationOptions, }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import { Pagination } from "@/components/ui/pagination"const [page, setPage] = React.useState(1)
<Pagination page={page} count={25} onPageChange={setPage} />Give Pagination a count and it builds the whole bar. It always shows the same number of slots, so Previous, Next and the numbers never shift under your cursor while you click through pages. Leave count out to compose the parts yourself.
Pagination
└── PaginationContent
├── PaginationItem
│ └── PaginationPrevious
├── PaginationItem
│ └── PaginationLink
├── PaginationItem
│ └── PaginationEllipsis
└── PaginationItem
└── PaginationNextJump to a page#
Click an ellipsis to type a page number. Enter jumps, an out-of-range number shakes the field, and Esc puts the ellipsis back. Turn it off with jump={false}.
"use client"
import * as React from "react"
import { Pagination } from "@/components/ui/pagination"
export function PaginationLong() {
const [page, setPage] = React.useState(480)
return <Pagination page={page} count={1000} onPageChange={setPage} />
}Compact#
When the bar doesn't fit its container, it switches to Previous, “Page 4 of 24” and Next, and switches back when there's room. The label is a jump field too. Force either layout with compact.
"use client"
import * as React from "react"
import { Pagination } from "@/components/ui/pagination"
export function PaginationCompact() {
const [page, setPage] = React.useState(4)
return (
<div className="w-full max-w-72 rounded-xl border p-2">
<Pagination page={page} count={24} onPageChange={setPage} />
</div>
)
}Links#
With getPageHref, every page is a real link, so it can be opened in a new tab. Plain clicks still call onPageChange.
"use client"
import * as React from "react"
import { Pagination } from "@/components/ui/pagination"
export function PaginationLinks() {
const [page, setPage] = React.useState(3)
return (
<Pagination
page={page}
count={12}
getPageHref={(target) => `#page-${target}`}
onPageChange={setPage}
/>
)
}Composable#
Without count, build the bar from the parts, as in shadcn/ui. Pass render to use your router's link.
"use client"
import * as React from "react"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
const pages = [1, 2, 3]
export function PaginationComposable() {
const [page, setPage] = React.useState(2)
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
render={<button type="button" />}
disabled={page === 1}
onClick={() => setPage(page - 1)}
/>
</PaginationItem>
{pages.map((item) => (
<PaginationItem key={item}>
<PaginationLink
render={<button type="button" />}
isActive={item === page}
onClick={() => setPage(item)}
>
{item}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationNext
render={<button type="button" />}
disabled={page === pages.length}
onClick={() => setPage(page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)
}Right to left#
usePagination returns the page and ellipsis items for custom layouts. Chevrons flip in right-to-left text.
"use client"
import * as React from "react"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
usePagination,
} from "@/components/ui/pagination"
export function PaginationRtl() {
const [page, setPage] = React.useState(2)
const { items, hasPrevious, hasNext } = usePagination({ page, count: 3 })
return (
<div dir="rtl">
<Pagination aria-label="التنقل بين الصفحات">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
text="السابق"
aria-label="الصفحة السابقة"
render={<button type="button" />}
disabled={!hasPrevious}
onClick={() => setPage(page - 1)}
/>
</PaginationItem>
{items.map((item) =>
item.type === "page" ? (
<PaginationItem key={item.page}>
<PaginationLink
render={<button type="button" />}
isActive={item.page === page}
onClick={() => setPage(item.page)}
>
{item.page.toLocaleString("ar-EG")}
</PaginationLink>
</PaginationItem>
) : null
)}
<PaginationItem>
<PaginationNext
text="التالي"
aria-label="الصفحة التالية"
render={<button type="button" />}
disabled={!hasNext}
onClick={() => setPage(page + 1)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}| Key | Action |
|---|---|
| Tab | Moves into and out of the bar. Previous and Next stay focusable at the ends. |
| ←→ | Moves between Previous, the pages and Next. Flips in right-to-left layouts. |
| HomeEnd | Moves to the first or last item. |
| Enter | Opens the focused page or the page field. In the field, jumps to that page and focuses it. |
| Esc | Closes the page field and returns focus to the ellipsis. |
- The root is a
<nav>labelled “Pagination”, and the current page hasaria-current="page". - At the first and last page, Previous and Next use
aria-disabledinstead ofdisabled, so focus isn't lost when you reach the end. - The sliding indicator becomes a fade when reduced motion is on.
| Prop | Type | Default |
|---|---|---|
countTotal pages. When set, the bar is built for you; leave it out to compose the parts. | number | – |
page | number | – |
defaultPage | number | 1 |
onPageChange | (page: number) => void | – |
getPageHrefRenders pages as links instead of buttons. | (page: number) => string | – |
siblingsPages shown on each side of the current one. | number | 1 |
boundariesPages always shown at the start and end. | number | 1 |
compactauto switches to Previous, “Page x of y” and Next when the bar doesn't fit. | boolean | "auto" | "auto" |
jumpLets the ellipsis open a page field. | boolean | true |
| Attribute | Description |
|---|---|
data-slot="pagination" | The nav. |
data-slot="pagination-indicator" | The background that glides to the current page. |
| Prop | Type | Default |
|---|---|---|
isActiveMarks the current page with aria-current="page". | boolean | false |
disabledSets aria-disabled and ignores clicks. | boolean | false |
render | ReactElement | (props, state) => ReactElement | <a> |
| Attribute | Description |
|---|---|
data-slot="pagination-link" | Target page links in CSS. |
data-active | Present on the current page. |
| Prop | Type | Default |
|---|---|---|
textPass null for an icon-only button. | ReactNode | "Previous" / "Next" |
disabled | boolean | false |
| Prop | Type | Default |
|---|---|---|
onJumpMakes the ellipsis a page field. Without it, the ellipsis is decorative. | (page: number) => void | – |
countThe highest page the field accepts. | number | – |
| Prop | Type | Default |
|---|---|---|
page | number | – |
count | number | – |
siblings | number | 1 |
boundaries | number | 1 |
Returns items, the clamped page and count, and hasPrevious and hasNext. See the usePagination guide for building your own pager.