diff --git a/packages/css/src/index.scss b/packages/css/src/index.scss
index 8ac71c86a4..c1cdf7b5ae 100644
--- a/packages/css/src/index.scss
+++ b/packages/css/src/index.scss
@@ -4,6 +4,7 @@
*/
/* Append here */
+@import "./pagination/pagination";
@import "./accordion/accordion";
@import "./alert/alert";
@import "./aspect-ratio/aspect-ratio";
diff --git a/packages/css/src/link/link.scss b/packages/css/src/link/link.scss
index 8668ee894c..3634ceab37 100644
--- a/packages/css/src/link/link.scss
+++ b/packages/css/src/link/link.scss
@@ -78,8 +78,8 @@
// Override for icon size
.amsterdam-link--in-list__chevron {
span.amsterdam-icon svg {
- height: 16px;
- width: 16px;
+ height: 1rem;
+ width: 1rem;
}
}
diff --git a/packages/css/src/pagination/README.md b/packages/css/src/pagination/README.md
new file mode 100644
index 0000000000..8f731d8497
--- /dev/null
+++ b/packages/css/src/pagination/README.md
@@ -0,0 +1,18 @@
+# Pagination
+
+Pagination (in het Nederlands: paginering) is een navigatie-element onder een zoekresultatenlijst. Bij grote hoeveelheden zoekresultaten kan het duidelijker of functioneler zijn om de inhoud over meerdere pagina´s te verdelen. Paginering toont op welke zoekresultatenlijst de gebruiker zich bevindt en kan hiermee naar een andere zoekresultatenlijst navigeren.
+
+## Richtlijnen
+
+- Gebruik paginering alleen op een zoekresultatenpagina.
+- Voeg de paginering toe na de lijst met zoekresultaten.
+- Start een zoekresultatenpagina bovenaan de pagina na het veranderen van pagina.
+- De paginering kan gecombineerd worden met een teller bovenaan de pagina die “Pagina # van ##” aanduidt.
+- De paginering wordt niet getoond als er maar 1 pagina is.
+- Verwijs de gebruikers door naar de eerste pagina als ze een URL opgeven van een paginanummer dat niet (meer) bestaat.
+
+## Relevante WCAG regels
+
+- [WCAG 2.4.8](https://www.w3.org/TR/WCAG22/#location): geef aan waar de gebruiker is in een verzameling van pagina's (AAA).
+
+Pagination is een interactief element, hier gelden [de algemene eisen en richtlijnen voor interactieve elementen](https://amsterdam.github.io/design-system/?path=/docs/docs-designrichtlijnen-interactieve-elementen--docs) voor.
diff --git a/packages/css/src/pagination/pagination.scss b/packages/css/src/pagination/pagination.scss
new file mode 100644
index 0000000000..0ee17e2c3e
--- /dev/null
+++ b/packages/css/src/pagination/pagination.scss
@@ -0,0 +1,75 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright (c) 2023 Gemeente Amsterdam
+ */
+
+@import "../../utils/breakpoint";
+
+@mixin list-reset {
+ list-style-type: none;
+ margin-block: 0;
+ padding-inline-start: 0;
+}
+
+.amsterdam-pagination__list {
+ color: var(--amsterdam-pagination-color);
+ display: flex;
+ flex-wrap: wrap;
+ font-family: var(--amsterdam-pagination-font-family);
+ font-size: var(--amsterdam-pagination-narrow-font-size);
+ font-weight: var(--amsterdam-pagination-font-weight);
+ justify-content: center;
+ line-height: var(--amsterdam-pagination-line-height);
+
+ @include list-reset;
+
+ @media screen and (width > $amsterdam-breakpoint-typography) {
+ font-size: var(--amsterdam-pagination-wide-font-size);
+ }
+}
+
+@mixin button-reset {
+ all: unset;
+ box-sizing: border-box;
+ outline: revert;
+ -webkit-text-size-adjust: 100%;
+}
+
+.amsterdam-pagination__button {
+ /* The reset is included at the top of the block here, if you set it
+ at the bottom `all: unset` overrides the `gap` property. */
+ @include button-reset;
+
+ cursor: pointer;
+ display: flex;
+ gap: 0.5rem;
+ outline-offset: var(--amsterdam-pagination-button-outline-offset);
+ padding-inline: 0.75rem;
+ text-decoration-thickness: 2px;
+ text-underline-offset: 3px;
+ touch-action: manipulation;
+
+ &:hover {
+ color: var(--amsterdam-pagination-button-hover-color);
+ text-decoration: underline;
+ }
+
+ &:disabled {
+ display: none;
+ }
+
+ // Override for icon size
+ span.amsterdam-icon svg {
+ height: 1rem;
+ width: 1rem;
+ }
+}
+
+.amsterdam-pagination__button--current {
+ cursor: default;
+ font-weight: var(--amsterdam-pagination-button-current-font-weight);
+
+ &:hover {
+ text-decoration: none;
+ }
+}
diff --git a/packages/react/src/Pagination/Pagination.test.tsx b/packages/react/src/Pagination/Pagination.test.tsx
new file mode 100644
index 0000000000..6c27b68e0f
--- /dev/null
+++ b/packages/react/src/Pagination/Pagination.test.tsx
@@ -0,0 +1,107 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { createRef, useState } from 'react'
+import { Pagination } from './Pagination'
+import '@testing-library/jest-dom'
+
+describe('Pagination', () => {
+ it('renders', () => {
+ const { container } = render()
+ const component = container.querySelector(':only-child')
+ expect(component).toBeInTheDocument()
+ expect(component).toBeVisible()
+ })
+
+ it('renders a design system BEM class name', () => {
+ const { container } = render()
+ const component = container.querySelector(':only-child')
+ expect(component).toHaveClass('amsterdam-pagination')
+ })
+
+ it('can have a additional class name', () => {
+ const { container } = render()
+ const component = container.querySelector(':only-child')
+ expect(component).toHaveClass('extra')
+ expect(component).toHaveClass('amsterdam-pagination')
+ })
+
+ it('should render all the pages when totalPages < maxVisiblePages', () => {
+ render()
+ expect(screen.getAllByRole('listitem').length).toBe(8) // 6 + 2 buttons
+ expect(screen.queryByTestId('lastSpacer')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('firstSpacer')).not.toBeInTheDocument()
+ })
+
+ it('should render the pages including one (last) spacer when totalPages > maxVisiblePages', () => {
+ render()
+ expect(screen.getAllByRole('listitem').length).toBe(8) // 6 + 2 buttons
+ expect(screen.getByTestId('lastSpacer')).toBeInTheDocument()
+ expect(screen.queryByTestId('firstSpacer')).not.toBeInTheDocument()
+ })
+
+ it('should render the pages including the two spacers when totalPages > maxVisiblePages and current page > 4', () => {
+ render()
+ expect(screen.getAllByRole('listitem').length).toBe(7) // 5 + 2 buttons
+ expect(screen.getByTestId('lastSpacer')).toBeInTheDocument()
+ expect(screen.getByTestId('firstSpacer')).toBeInTheDocument()
+ })
+
+ it('should navigate to the next page when clicking on the "next" button', () => {
+ const onPageChangeMock = jest.fn()
+ render()
+
+ expect(onPageChangeMock).not.toHaveBeenCalled()
+ expect(screen.getByText('6')).toHaveAttribute('aria-current', 'true')
+ expect(screen.getByText('7')).not.toHaveAttribute('aria-current', 'true')
+
+ fireEvent.click(screen.getByText('volgende'))
+
+ expect(onPageChangeMock).toHaveBeenCalled()
+ expect(screen.getByText('6')).not.toHaveAttribute('aria-current', 'true')
+ expect(screen.getByText('7')).toHaveAttribute('aria-current', 'true')
+ })
+
+ it('should navigate to the previous page when clicking on the "previous" button', () => {
+ const onPageChangeMock = jest.fn()
+ render()
+
+ expect(onPageChangeMock).not.toHaveBeenCalled()
+ expect(screen.getByText('6')).toHaveAttribute('aria-current', 'true')
+ expect(screen.getByText('5')).not.toHaveAttribute('aria-current', 'true')
+
+ fireEvent.click(screen.getByText('vorige'))
+
+ expect(onPageChangeMock).toHaveBeenCalled()
+ expect(screen.getByText('6')).not.toHaveAttribute('aria-current', 'true')
+ expect(screen.getByText('5')).toHaveAttribute('aria-current', 'true')
+ })
+
+ it('should be working in a controlled state', () => {
+ function ControlledComponent() {
+ const [page, setPage] = useState(6)
+
+ return
+ }
+
+ render()
+
+ expect(screen.getByText('6')).toHaveAttribute('aria-current', 'true')
+ expect(screen.getByText('5')).not.toHaveAttribute('aria-current', 'true')
+
+ fireEvent.click(screen.getByText('vorige'))
+
+ expect(screen.getByText('6')).not.toHaveAttribute('aria-current', 'true')
+ expect(screen.getByText('5')).toHaveAttribute('aria-current', 'true')
+
+ fireEvent.click(screen.getByText('volgende'))
+
+ expect(screen.getByText('6')).toHaveAttribute('aria-current', 'true')
+ expect(screen.getByText('5')).not.toHaveAttribute('aria-current', 'true')
+ })
+
+ it('supports ForwardRef in React', () => {
+ const ref = createRef()
+ const { container } = render()
+ const component = container.querySelector(':only-child')
+ expect(ref.current).toBe(component)
+ })
+})
diff --git a/packages/react/src/Pagination/Pagination.tsx b/packages/react/src/Pagination/Pagination.tsx
new file mode 100644
index 0000000000..6ee4313b9c
--- /dev/null
+++ b/packages/react/src/Pagination/Pagination.tsx
@@ -0,0 +1,174 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright (c) 2023 Gemeente Amsterdam
+ */
+
+import { ChevronLeftIcon, ChevronRightIcon } from '@amsterdam/design-system-react-icons'
+import clsx from 'clsx'
+import { ForwardedRef, forwardRef, HTMLAttributes, useMemo, useState } from 'react'
+import { Icon } from '../Icon/Icon'
+
+export interface PaginationProps extends HTMLAttributes {
+ /**
+ * The maximum amount of pages shown. This has a lower limit of 5
+ */
+ maxVisiblePages?: number
+ /**
+ * Callback triggered when interaction changes the page number.
+ */
+ // eslint-disable-next-line no-unused-vars
+ onPageChange?: (page: number) => void
+ /**
+ * The current page number.
+ */
+ page?: number
+ /**
+ * The total number of pages.
+ */
+ totalPages: number
+}
+
+/**
+ * This returns an array of the range, including spacers
+ *
+ * @example
+ * currentPage = 4, totalPages = 7, maxVisiblePages = 7
+ * // returns [1, 2, 3, 4, 5, 6, 7]
+ *
+ * @example
+ * currentPage = 5, totalPages = 100, maxVisiblePages = 7
+ * // returns [1, 'firstSpacer', 4, 5, 6, 'lastSpacer', 100]
+ *
+ * @example
+ * currentPage = 97, totalPages = 100, maxVisiblePages = 7
+ * // returns [1, 'firstSpacer', 96, 97, 98, 99, 100]
+ */
+
+function getRange(currentPage: number, totalPages: number, maxVisiblePages: number): Array {
+ // the total amount of visible pages is whatever's lower, totalPages or maxVisiblePages
+ // maxVisiblePages has a lower limit of 5
+ const visiblePages = Math.min(totalPages, Math.max(maxVisiblePages, 5))
+
+ const min = 1
+ // the center part of the range starts with the current page minus half of the visible pages
+ let centerStartPage = currentPage - Math.floor(visiblePages / 2)
+ // centerStartPage has a lower limit of 1
+ centerStartPage = Math.max(centerStartPage, min)
+ // centerStartPage has an upper limit of 1 plus total pages minus visible pages
+ centerStartPage = Math.min(centerStartPage, min + totalPages - visiblePages)
+
+ const pages = Array.from({ length: visiblePages }, (_el, i) => centerStartPage + i).reduce>(
+ (acc, pageNr, index) => {
+ if (index === 0 && pageNr !== 1) {
+ return [1, 'firstSpacer']
+ }
+
+ if (totalPages > visiblePages && index === visiblePages - 2 && currentPage < totalPages - 2) {
+ return [...acc, 'lastSpacer', totalPages]
+ }
+ // Skip a number when spacer is already added
+ if ((acc.includes('firstSpacer') && index === 1) || (acc.includes('lastSpacer') && index === visiblePages - 1)) {
+ return acc
+ }
+ return [...acc, pageNr]
+ },
+ [],
+ )
+
+ return pages
+}
+
+export const Pagination = forwardRef(
+ (
+ { className, maxVisiblePages = 7, onPageChange, page = 1, totalPages, ...restProps }: PaginationProps,
+ ref: ForwardedRef,
+ ) => {
+ const [currentPage, setCurrentPage] = useState(page)
+
+ // Get array of page numbers and / or spacers
+ const range = useMemo(
+ () => getRange(currentPage, totalPages, maxVisiblePages),
+ [currentPage, totalPages, maxVisiblePages],
+ )
+
+ const onChangePage = (newPage: number) => {
+ if (onPageChange !== undefined) {
+ onPageChange(newPage)
+ }
+ setCurrentPage(newPage)
+ }
+
+ const onPrevious = () => {
+ onChangePage(currentPage - 1)
+ }
+
+ const onNext = () => {
+ onChangePage(currentPage + 1)
+ }
+
+ // Don't show pagination if you only have one page
+ if (totalPages <= 1) {
+ return null
+ }
+
+ return (
+
+ )
+ },
+)
+
+Pagination.displayName = 'Pagination'
diff --git a/packages/react/src/Pagination/README.md b/packages/react/src/Pagination/README.md
new file mode 100644
index 0000000000..24159b2865
--- /dev/null
+++ b/packages/react/src/Pagination/README.md
@@ -0,0 +1,3 @@
+# React Pagination component
+
+[Pagination documentation](../../../css/src/pagination/README.md)
diff --git a/packages/react/src/Pagination/index.ts b/packages/react/src/Pagination/index.ts
new file mode 100644
index 0000000000..f1805927d1
--- /dev/null
+++ b/packages/react/src/Pagination/index.ts
@@ -0,0 +1,2 @@
+export { Pagination } from './Pagination'
+export type { PaginationProps } from './Pagination'
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 73f0349eda..87b49ab25c 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -4,6 +4,7 @@
*/
/* Append here */
+export * from './Pagination'
export * from './Screen'
export * from './Switch'
export * from './Highlight'
diff --git a/proprietary/tokens/src/components/amsterdam/pagination.tokens.json b/proprietary/tokens/src/components/amsterdam/pagination.tokens.json
new file mode 100644
index 0000000000..800f245ec7
--- /dev/null
+++ b/proprietary/tokens/src/components/amsterdam/pagination.tokens.json
@@ -0,0 +1,31 @@
+{
+ "amsterdam": {
+ "pagination": {
+ "color": { "value": "{amsterdam.color.primary-blue}" },
+ "font-family": {
+ "value": "{amsterdam.typography.font-family}"
+ },
+ "font-weight": {
+ "value": "{amsterdam.typography.font-weight.normal}"
+ },
+ "line-height": { "value": "{amsterdam.typography.text-level.6.line-height}" },
+ "narrow": {
+ "font-size": { "value": "{amsterdam.typography.text-level.6.narrow.font-size}" }
+ },
+ "wide": {
+ "font-size": { "value": "{amsterdam.typography.text-level.6.wide.font-size}" }
+ },
+ "button": {
+ "current": {
+ "font-weight": {
+ "value": "{amsterdam.typography.font-weight.bold}"
+ }
+ },
+ "hover": {
+ "color": { "value": "{amsterdam.color.dark-blue}" }
+ },
+ "outline-offset": { "value": "{amsterdam.focus.outline-offset}" }
+ }
+ }
+ }
+}
diff --git a/storybook/storybook-react/src/Pagination/Pagination.docs.mdx b/storybook/storybook-react/src/Pagination/Pagination.docs.mdx
new file mode 100644
index 0000000000..7baea8c290
--- /dev/null
+++ b/storybook/storybook-react/src/Pagination/Pagination.docs.mdx
@@ -0,0 +1,11 @@
+import { Controls, Markdown, Meta, Primary } from "@storybook/blocks";
+import * as PaginationStories from "./Pagination.stories.tsx";
+import README from "../../../../packages/css/src/pagination/README.md?raw";
+
+
+
+{README}
+
+
+
+
diff --git a/storybook/storybook-react/src/Pagination/Pagination.stories.tsx b/storybook/storybook-react/src/Pagination/Pagination.stories.tsx
new file mode 100644
index 0000000000..50f30162b9
--- /dev/null
+++ b/storybook/storybook-react/src/Pagination/Pagination.stories.tsx
@@ -0,0 +1,24 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright (c) 2023 Gemeente Amsterdam
+ */
+
+import { Pagination } from '@amsterdam/design-system-react'
+import { Meta, StoryObj } from '@storybook/react'
+
+const meta = {
+ title: 'Navigation/Pagination',
+ component: Pagination,
+ args: {
+ page: 1,
+ maxVisiblePages: 7,
+ totalPages: 10,
+ },
+ argTypes: { onPageChange: { action: 'page changed' } },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}