Pagination
Pagination helps users navigate through multiple pages, slides, or items. Choose the right pattern based on your use case:
Page numbers
Section titled “Page numbers”Navigate through paginated data with numbered buttons and prev/next arrows. Use usePagination to generate the page list.
import { type PaginationItem, usePagination } from '@volvo-cars/react-headless';import { Icon, IconButton } from '@volvo-cars/react-icons';
export function PagePagination() { const { setPage, pages, previousButtonProps, nextButtonProps } = usePagination({ totalPages: 10, initialPage: 5 });
return ( <nav aria-label="pagination" className="flex-row gap-8"> <ul style={{ display: 'contents', listStyle: 'none' }}> <li> <IconButton {...previousButtonProps} icon="chevron-back" aria-label="Go to previous page" variant="clear" /> </li> {pages.map((page: PaginationItem) => { if (page.type === 'ELLIPSIS') { return ( <li key={page.type} className="w-40 flex-row items-end justify-center" > <Icon icon="three-dots" size={16} alt="Hidden pages" /> </li> ); } if (page.type === 'ELLIPSIS_SMALL') { return ( <li key={page.type} className="w-40 flex-row items-end justify-center lg:hidden" > <Icon icon="three-dots" size={16} alt="Hidden pages" /> </li> ); } return ( <li key={page.value} className={page.isAdditional ? 'until-lg:hidden' : ''} > <button type="button" aria-label={`Go to page ${page.value}`} aria-current={page.isActive ? 'page' : undefined} className="icon-button-clear current:bg-surface-neutral current:text-inverted" onClick={() => setPage(page.value as number)} > {page.value} </button> </li> ); })} <li> <IconButton {...nextButtonProps} icon="chevron-forward" aria-label="Go to next page" variant="clear" /> </li> </ul> </nav> );}Use dots for carousels where each dot represents a single visible item. Best for 5 or fewer items.
<ul class="pagination-dots"> <li> <button type="button" aria-current="true"> <span class="sr-only">Go to slide 1</span> </button> </li> <li> <button type="button"> <span class="sr-only">Go to slide 2</span> </button> </li> <li> <button type="button"> <span class="sr-only">Go to slide 3</span> </button> </li> <li> <button type="button"> <span class="sr-only">Go to slide 4</span> </button> </li> <li> <button type="button"> <span class="sr-only">Go to slide 5</span> </button> </li></ul>Apply the pagination-dots class to a container of buttons. Mark the active dot with aria-current="true".
<div class="pagination-dots"> <button aria-current="true"><span class="sr-only">Slide 1</span></button> <button><span class="sr-only">Slide 2</span></button> <button><span class="sr-only">Slide 3</span></button></div>With scroll-snap carousel
Section titled “With scroll-snap carousel”Combine with useSnapIndicators and useSnapNavigation for a complete carousel:
import { useSnapIndicators, useSnapNavigation,} from '@volvo-cars/react-headless';import { IconButton } from '@volvo-cars/react-icons';import { useRef } from 'react';
export function PaginationDotsCarousel() { const ref = useRef<HTMLDivElement>(null); const { previousButtonProps, nextButtonProps } = useSnapNavigation({ ref }); const { indicators } = useSnapIndicators({ ref });
const count = 7;
return ( <section className="container-sm stack-16 py-s" data-bleed="until-md" aria-label="Carousel" > <div ref={ref} className="reel scrollbar-none"> {[...Array(count).keys()].map((key, index) => ( <div id={`dots-frame-${index + 1}`} className="snap-center aspect-4/3 w-full flex-shrink-0 flex items-center justify-center bg-feedback-green" key={key} > <h3 className="text-center" id={`dots-frame-${index + 1}-title`}> Frame {index + 1} </h3> </div> ))} </div> <ul className="pagination-dots"> {indicators.map((props, index) => ( <li key={props.id}> <button {...props}> <span className="sr-only">Go to frame {index + 1}</span> </button> </li> ))} </ul> <div className="container-sm flex gap-16 mt-24 justify-end"> <IconButton icon="chevron-back" variant="filled" color="subtle" {...previousButtonProps} /> <IconButton icon="chevron-forward" variant="filled" color="subtle" {...nextButtonProps} /> </div> </section> );}Numeric position
Section titled “Numeric position”Display position as text (e.g., “3 / 10”) for slideshows or galleries where the total count matters.
import { useSnapIndicators, useSnapNavigation,} from '@volvo-cars/react-headless';import { IconButton } from '@volvo-cars/react-icons';import { useRef } from 'react';
export function PaginationNumbers() { const ref = useRef<HTMLDivElement>(null); const { previousButtonProps, nextButtonProps } = useSnapNavigation({ ref }); const { indicators, activeIndex } = useSnapIndicators({ ref });
return ( <div className="container-sm py-s"> <section className="stack-16" data-bleed="until-md" aria-label="Carousel"> <div ref={ref} className="reel scrollbar-none"> {[...Array(7).keys()].map((key, index) => ( <div key={key} id={`numbers-frame-${index + 1}`} className="snap-center aspect-4/3 w-full flex-shrink-0 flex items-center justify-center bg-feedback-green" > <h3 className="text-center" id={`numbers-frame-${index + 1}-title`} > Frame {index + 1} </h3> </div> ))} </div> <div className="font-16 mt-24 flex justify-center"> {activeIndex + 1}/{indicators.length} </div> <div className="container-sm flex gap-16 justify-end"> <IconButton icon="chevron-back" variant="filled" color="subtle" {...previousButtonProps} /> <IconButton icon="chevron-forward" variant="filled" color="subtle" {...nextButtonProps} /> </div> </section> </div> );}Use activeIndex from useSnapIndicators to show the current position and indicators.length for the total.
With filled pill overlay
Section titled “With filled pill overlay”Combine pagination dots with an absolute-positioned pill-filled overlay showing the numeric position:
import { useSnapIndicators, useSnapNavigation,} from '@volvo-cars/react-headless';import { IconButton } from '@volvo-cars/react-icons';import { useRef } from 'react';
export function PaginationNumbersFilled() { const ref = useRef<HTMLDivElement>(null); const { previousButtonProps, nextButtonProps } = useSnapNavigation({ ref }); const { indicators, activeIndex } = useSnapIndicators({ ref });
return ( <div className="container-sm relative py-s"> <span className="pill-filled absolute z-overlay mt-24 end-24"> {activeIndex + 1} / {indicators.length} </span> <section className="stack-16" data-bleed="until-md" aria-label="Carousel"> <div ref={ref} className="reel scrollbar-none"> {[...Array(7).keys()].map((key, index) => ( <div id={`filled-frame-${index + 1}`} className="snap-center aspect-4/3 w-full flex-shrink-0 flex items-center justify-center bg-feedback-green" key={key} > <h3 className="text-center" id={`filled-frame-${index + 1}-title`} > Frame {index + 1} </h3> </div> ))} </div> <ul className="pagination-dots"> {indicators.map((props, index) => { return ( <li key={props.id}> <button {...props}> <span className="sr-only">Go to frame {index + 1}</span> </button> </li> ); })} </ul> <div className="container-sm flex gap-16 mt-24 justify-end"> <IconButton icon="chevron-back" variant="filled" color="subtle" {...previousButtonProps} /> <IconButton icon="chevron-forward" variant="filled" color="subtle" {...nextButtonProps} /> </div> </section> </div> );}Accessibility
Section titled “Accessibility”- Container label — add
aria-label="Pagination"to the<nav>wrapper - Active indicator — use
aria-current="page"(page numbers) oraria-current="true"(dots) - Button labels — provide descriptive text, e.g.,
aria-label="Go to page 5"or usesr-onlyspans for dots - Keyboard — ensure Tab navigates between buttons and Enter/Space activates them
usePagination— generates page lists with ellipsis for numbered paginationuseSnapIndicators— returns indicator state for scroll-snap carouselsuseSnapNavigation— returns prev/next button props for scroll-snap containers