Collection Page Components Intermediate 12 min read

Sorting and Pagination Components

Build sorting dropdown and pagination components for collection pages. Handle URL state and provide smooth navigation between pages.

Sorting and pagination are essential for navigating large product collections. In this lesson, we’ll build accessible, URL-synced components for both.

Theme Integration

These components are part of the collection page component hierarchy:

sections/collection-products.liquid
└── <div id="collection-products-root">
└── CollectionPage (React)
├── SortDropdown ← You are here
└── Pagination ← You are here

See Collection Page Architecture for the complete Liquid section setup, including sort options and pagination data.

Data Source

Prop/StateSourceLiquid Field
options (sort)Liquid JSONCustom sort options defined in section
sortByZustand storeURL param sort_by / collection.sort_by
currentPageZustand storeURL param page / paginate.current_page
totalPagesZustand storepaginate.pages
totalProductsZustand storepaginate.items

Sort Dropdown Component

src/components/collection/SortDropdown/SortDropdown.tsx
import { useState, useRef, useEffect } from 'react';
import { useCollection } from '@/stores/collection';
import styles from './SortDropdown.module.css';
interface SortOption {
value: string; // The sort_by parameter value (e.g., "price-ascending").
label: string; // Display label (e.g., "Price: Low to High").
}
interface SortDropdownProps {
options: SortOption[]; // Available sort options from Liquid.
}
/**
* SortDropdown provides a custom dropdown for selecting sort order.
* Uses proper ARIA roles for accessibility.
*/
export function SortDropdown({ options }: SortDropdownProps) {
const [isOpen, setIsOpen] = useState(false); // Dropdown open/closed state.
const sortBy = useCollection((state) => state.sortBy);
const setSort = useCollection((state) => state.setSort);
const dropdownRef = useRef<HTMLDivElement>(null); // For click-outside detection.
// Find the current option to display its label.
const currentOption = options.find((option) => option.value === sortBy) || options[0];
// Close dropdown when clicking outside.
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Close dropdown on Escape key for accessibility.
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, []);
// Handle option selection - update store and close dropdown.
const handleSelect = (value: string) => {
setSort(value); // Update sort in store (triggers fetch).
setIsOpen(false);
};
return (
<div ref={dropdownRef} className={styles.dropdown}>
{/* Trigger button */}
<button
type="button"
className={styles.trigger}
onClick={() => setIsOpen(!isOpen)}
aria-haspopup="listbox" // Indicates a listbox will appear.
aria-expanded={isOpen} // Communicates current state.
>
<span className={styles.label}>Sort by:</span>
<span className={styles.value}>{currentOption.label}</span>
<ChevronIcon className={`${styles.chevron} ${isOpen ? styles.open : ''}`} />
</button>
{/* Dropdown menu */}
{isOpen && (
<ul className={styles.menu} role="listbox" aria-label="Sort options">
{options.map((option) => (
<li key={option.value} role="option" aria-selected={option.value === sortBy}>
<button
type="button"
className={`${styles.option} ${option.value === sortBy ? styles.selected : ''}`}
onClick={() => handleSelect(option.value)}
>
{option.label}
{/* Show checkmark for selected option */}
{option.value === sortBy && <CheckIcon />}
</button>
</li>
))}
</ul>
)}
</div>
);
}
/**
* Chevron icon that rotates when dropdown is open.
*/
function ChevronIcon({ className }: { className?: string }) {
return (
<svg className={className} width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 6l4 4 4-4" />
</svg>
);
}
/**
* Checkmark icon for selected option indicator.
*/
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M13.5 4.5L6 12 2.5 8.5" />
</svg>
);
}
src/components/collection/SortDropdown/SortDropdown.module.css
.dropdown {
position: relative;
}
.trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
cursor: pointer;
transition: border-color 0.15s ease;
}
.trigger:hover {
border-color: var(--color-text);
}
.label {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.value {
font-size: 0.875rem;
font-weight: 500;
}
.chevron {
transition: transform 0.2s ease;
}
.chevron.open {
transform: rotate(180deg);
}
.menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 50;
min-width: 200px;
margin: 0;
padding: 0.5rem 0;
list-style: none;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.option {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 0.625rem 1rem;
border: none;
background: transparent;
text-align: left;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.15s ease;
}
.option:hover {
background: var(--color-background-subtle);
}
.option.selected {
font-weight: 500;
}

Pagination Component

src/components/collection/Pagination/Pagination.tsx
import { useCollection } from '@/stores/collection';
import styles from './Pagination.module.css';
/**
* Pagination provides numbered page navigation with prev/next arrows.
* Uses smart ellipsis display for large page counts.
*/
export function Pagination() {
const currentPage = useCollection((state) => state.currentPage);
const totalPages = useCollection((state) => state.totalPages);
const setPage = useCollection((state) => state.setPage);
// Don't render pagination if there's only one page.
if (totalPages <= 1) return null;
// Get smart page numbers with ellipsis for large counts.
const pages = getPageNumbers(currentPage, totalPages);
return (
<nav className={styles.pagination} aria-label="Pagination">
{/* Previous page arrow */}
<button
type="button"
className={`${styles.arrow} ${styles.prev}`}
onClick={() => setPage(currentPage - 1)}
disabled={currentPage === 1} // Disabled on first page.
aria-label="Previous page"
>
<ArrowIcon direction="left" />
</button>
{/* Page numbers with ellipsis */}
<div className={styles.pages}>
{pages.map((page, index) =>
page === '...' ? (
// Render ellipsis as static span.
<span key={`ellipsis-${index}`} className={styles.ellipsis}>
...
</span>
) : (
// Render clickable page number button.
<button
key={page}
type="button"
className={`${styles.page} ${page === currentPage ? styles.current : ''}`}
onClick={() => setPage(page as number)}
aria-label={`Page ${page}`}
aria-current={page === currentPage ? 'page' : undefined} // Accessibility: mark current page.
>
{page}
</button>
)
)}
</div>
{/* Next page arrow */}
<button
type="button"
className={`${styles.arrow} ${styles.next}`}
onClick={() => setPage(currentPage + 1)}
disabled={currentPage === totalPages} // Disabled on last page.
aria-label="Next page"
>
<ArrowIcon direction="right" />
</button>
</nav>
);
}
/**
* Generates an array of page numbers with ellipsis for large page counts.
* Always shows first and last page, current page, and adjacent pages.
* Example outputs:
* - Total 5 pages: [1, 2, 3, 4, 5]
* - Total 10, current 2: [1, 2, 3, 4, 5, '...', 10]
* - Total 10, current 5: [1, '...', 4, 5, 6, '...', 10]
* - Total 10, current 9: [1, '...', 6, 7, 8, 9, 10]
*/
function getPageNumbers(current: number, total: number): (number | string)[] {
// If 7 or fewer pages, show all numbers without ellipsis.
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
// Near the beginning: show first 5, ellipsis, last.
if (current <= 3) {
return [1, 2, 3, 4, 5, '...', total];
}
// Near the end: show first, ellipsis, last 5.
if (current >= total - 2) {
return [1, '...', total - 4, total - 3, total - 2, total - 1, total];
}
// In the middle: show first, ellipsis, current±1, ellipsis, last.
return [1, '...', current - 1, current, current + 1, '...', total];
}
/**
* Left/right arrow icon for prev/next buttons.
*/
function ArrowIcon({ direction }: { direction: 'left' | 'right' }) {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
<path d={direction === 'left' ? 'M10 12L6 8l4-4' : 'M6 12l4-4-4-4'} />
</svg>
);
}
src/components/collection/Pagination/Pagination.module.css
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 3rem;
padding: 1.5rem 0;
}
.pages {
display: flex;
align-items: center;
gap: 0.25rem;
}
.page,
.arrow {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
padding: 0 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease;
}
.page:hover:not(.current),
.arrow:hover:not(:disabled) {
border-color: var(--color-text);
}
.page.current {
background: var(--color-text);
border-color: var(--color-text);
color: var(--color-background);
font-weight: 600;
}
.arrow:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ellipsis {
padding: 0 0.5rem;
color: var(--color-text-muted);
}

URL State Synchronization

Keep pagination in sync with URL:

// In your collection store - setPage action implementation
setPage: (page) => {
set({ currentPage: page }); // Update local state immediately.
// Sync with URL for shareable/bookmarkable links.
const url = new URL(window.location.href);
if (page === 1) {
// Remove page param for page 1 to keep URL clean.
url.searchParams.delete('page');
} else {
url.searchParams.set('page', String(page));
}
// Use pushState (not replaceState) so browser back button works.
window.history.pushState({}, '', url.toString());
// Fetch products with the new page.
get().fetchProducts();
// Scroll to top of product grid for better UX.
document.getElementById('collection-products-root')?.scrollIntoView({
behavior: 'smooth', // Smooth scroll animation.
block: 'start', // Align to top of viewport.
});
},

Load More Button Alternative

src/components/collection/LoadMoreButton/LoadMoreButton.tsx
import { useCollection } from '@/stores/collection';
import { Button, Spinner } from '@/components/ui';
import styles from './LoadMoreButton.module.css';
/**
* LoadMoreButton provides an alternative to pagination.
* Appends next page's products to the current grid instead of replacing.
*/
export function LoadMoreButton() {
const currentPage = useCollection((state) => state.currentPage);
const totalPages = useCollection((state) => state.totalPages);
const isLoading = useCollection((state) => state.isLoading);
const loadMore = useCollection((state) => state.loadMore);
// Don't show button if we've loaded all pages.
if (currentPage >= totalPages) return null;
return (
<div className={styles.container}>
<Button variant="secondary" onClick={loadMore} disabled={isLoading}>
{isLoading ? (
// Show loading state with spinner.
<>
<Spinner size="small" />
Loading...
</>
) : (
// Show remaining page count.
`Load More (${totalPages - currentPage} pages remaining)`
)}
</Button>
</div>
);
}

Product Count Display

src/components/collection/ProductCount/ProductCount.tsx
import { useCollection } from '@/stores/collection';
import styles from './ProductCount.module.css';
/**
* ProductCount displays the current position in the result set.
* Example: "Showing 25-48 of 120 products"
*/
export function ProductCount() {
const products = useCollection((state) => state.products);
const totalProducts = useCollection((state) => state.totalProducts);
const currentPage = useCollection((state) => state.currentPage);
const isLoading = useCollection((state) => state.isLoading);
// Calculate the range of products being shown.
// Note: This assumes consistent page sizes; adjust if page sizes vary.
const start = (currentPage - 1) * products.length + 1;
const end = start + products.length - 1;
// Show loading state during fetches.
if (isLoading) {
return <span className={styles.count}>Loading...</span>;
}
return (
<span className={styles.count}>
Showing {start}-{end} of {totalProducts} products
</span>
);
}

Key Takeaways

  1. Dropdown accessibility: Use aria-haspopup, aria-expanded, and role="listbox"
  2. Smart page numbers: Show ellipsis for large page counts
  3. URL persistence: Sync page and sort with URL for sharing
  4. Scroll to top: Scroll to grid when changing pages
  5. Disabled states: Clear visual feedback for first/last page
  6. Load more alternative: Progressive loading instead of pagination
  7. Product count: Show position in result set

Your Collection Page components are complete! In the next module, we’ll build Product Page components.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...