diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss
index b95b00fbdd..3ce99af8f0 100644
--- a/packages/css/src/components/index.scss
+++ b/packages/css/src/components/index.scss
@@ -4,6 +4,7 @@
*/
/* Append here */
+@import "./table-of-contents/table-of-contents";
@import "./field/field";
@import "./select/select";
@import "./time-input/time-input";
diff --git a/packages/css/src/components/table-of-contents/README.md b/packages/css/src/components/table-of-contents/README.md
new file mode 100644
index 0000000000..4541a1701b
--- /dev/null
+++ b/packages/css/src/components/table-of-contents/README.md
@@ -0,0 +1,3 @@
+
+
+# Table of Contents
diff --git a/packages/css/src/components/table-of-contents/table-of-contents.scss b/packages/css/src/components/table-of-contents/table-of-contents.scss
new file mode 100644
index 0000000000..21c2d3fba3
--- /dev/null
+++ b/packages/css/src/components/table-of-contents/table-of-contents.scss
@@ -0,0 +1,78 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+@import "../../common/text-rendering";
+
+@mixin reset {
+ box-sizing: border-box;
+ margin-block: 0;
+ padding-inline: 0;
+}
+
+.ams-table-of-contents {
+ display: flex;
+ flex-direction: column;
+ font-family: var(--ams-table-of-contents-font-family, inherit);
+ font-size: var(--ams-table-of-contents-font-size);
+ font-weight: var(--ams-table-of-contents-font-weight);
+ gap: var(--ams-table-of-contents-gap);
+ line-height: var(--ams-table-of-contents-line-height);
+}
+
+.ams-table-of-contents__list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ams-table-of-contents-list-gap);
+ list-style: none;
+
+ .ams-table-of-contents__list {
+ display: none;
+ padding-block-start: var(--ams-table-of-contents-list-gap);
+ padding-inline-start: var(--ams-table-of-contents-list-list-padding-inline-start);
+ }
+
+ @include text-rendering;
+ @include reset;
+}
+
+.ams-table-of-contents__title {
+ font-size: var(--ams-table-of-contents-title-font-size);
+ font-weight: var(--ams-table-of-contents-title-font-weight);
+ line-height: var(--ams-table-of-contents-title-line-height);
+}
+
+.ams-table-of-contents__item:has(.ams-table-of-contents) {
+ background-image: var(--ams-table-of-contents-list-item-background-image);
+ background-position-x: right;
+ background-position-y: 0.3em;
+ background-repeat: no-repeat;
+ background-size: var(--ams-table-of-contents-font-size);
+
+ &:has(.ams-table-of-contents__link--active),
+ .ams-table-of-contents__item:has(.ams-table-of-contents) {
+ background-image: var(--ams-table-of-contents-list-item-active-background-image);
+
+ .ams-table-of-contents__list {
+ display: flex;
+ }
+ }
+}
+
+.ams-table-of-contents__link {
+ color: var(--ams-table-of-contents-link-color);
+ outline-offset: var(--ams-table-of-contents-link-outline-offset);
+ text-decoration-line: var(--ams-table-of-contents-link-text-decoration-line);
+ text-decoration-thickness: var(--ams-table-of-contents-link-text-decoration-thickness);
+ text-underline-offset: var(--ams-table-of-contents-link-text-underline-offset);
+
+ &--active {
+ font-weight: 700;
+ }
+
+ &:hover {
+ color: var(--ams-table-of-contents-link-hover-color);
+ text-decoration-line: var(--ams-table-of-contents-link-hover-text-decoration-line);
+ }
+}
diff --git a/packages/react/src/TableOfContents/README.md b/packages/react/src/TableOfContents/README.md
new file mode 100644
index 0000000000..6edaf03ab9
--- /dev/null
+++ b/packages/react/src/TableOfContents/README.md
@@ -0,0 +1,5 @@
+
+
+# React Table of Contents component
+
+[Table of Contents documentation](../../../css/src/components/table-of-contents/README.md)
diff --git a/packages/react/src/TableOfContents/TableOfContents.test.tsx b/packages/react/src/TableOfContents/TableOfContents.test.tsx
new file mode 100644
index 0000000000..ca180df95d
--- /dev/null
+++ b/packages/react/src/TableOfContents/TableOfContents.test.tsx
@@ -0,0 +1,37 @@
+import { render } from '@testing-library/react'
+import { createRef } from 'react'
+import { TableOfContents } from './TableOfContents'
+import '@testing-library/jest-dom'
+
+describe('Table of contents', () => {
+ 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('ams-table-of-contents')
+ })
+
+ it('renders an additional class name', () => {
+ const { container } = render()
+ const component = container.querySelector(':only-child')
+
+ expect(component).toHaveClass('ams-table-of-contents extra')
+ })
+
+ 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/TableOfContents/TableOfContents.tsx b/packages/react/src/TableOfContents/TableOfContents.tsx
new file mode 100644
index 0000000000..22d2398640
--- /dev/null
+++ b/packages/react/src/TableOfContents/TableOfContents.tsx
@@ -0,0 +1,26 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import clsx from 'clsx'
+import { forwardRef } from 'react'
+import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react'
+import { TableOfContentsLink } from './TableOfContentsLink'
+
+export type TableOfContentsProps = {
+ title?: string
+} & PropsWithChildren>
+
+const TableOfContentsRoot = forwardRef(
+ ({ children, className, title, ...restProps }: TableOfContentsProps, ref: ForwardedRef) => (
+
+ ),
+)
+
+TableOfContentsRoot.displayName = 'TableOfContents'
+
+export const TableOfContents = Object.assign(TableOfContentsRoot, { Link: TableOfContentsLink })
diff --git a/packages/react/src/TableOfContents/TableOfContentsLink.tsx b/packages/react/src/TableOfContents/TableOfContentsLink.tsx
new file mode 100644
index 0000000000..7c3e88a2fd
--- /dev/null
+++ b/packages/react/src/TableOfContents/TableOfContentsLink.tsx
@@ -0,0 +1,33 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import { clsx } from 'clsx'
+import { forwardRef } from 'react'
+import type { AnchorHTMLAttributes, ForwardedRef } from 'react'
+
+export type TableOfContentsLinkProps = {
+ label: string
+ active?: boolean
+} & AnchorHTMLAttributes
+
+export const TableOfContentsLink = forwardRef(
+ (
+ { children, className, label, active, ...restProps }: TableOfContentsLinkProps,
+ ref: ForwardedRef,
+ ) => (
+
+
+ {label}
+
+ {children}
+
+ ),
+)
+
+TableOfContentsLink.displayName = 'TableOfContents.Link'
diff --git a/packages/react/src/TableOfContents/index.ts b/packages/react/src/TableOfContents/index.ts
new file mode 100644
index 0000000000..44552e9ae3
--- /dev/null
+++ b/packages/react/src/TableOfContents/index.ts
@@ -0,0 +1,2 @@
+export { TableOfContents } from './TableOfContents'
+export type { TableOfContentsProps } from './TableOfContents'
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index d6b0c721f6..5b86f95173 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -4,6 +4,7 @@
*/
/* Append here */
+export * from './TableOfContents'
export * from './Field'
export * from './Select'
export * from './TimeInput'
diff --git a/proprietary/tokens/src/components/ams/table-of-contents.tokens.json b/proprietary/tokens/src/components/ams/table-of-contents.tokens.json
new file mode 100644
index 0000000000..4b562d6594
--- /dev/null
+++ b/proprietary/tokens/src/components/ams/table-of-contents.tokens.json
@@ -0,0 +1,40 @@
+{
+ "ams": {
+ "table-of-contents": {
+ "color": { "value": "{ams.color.primary-black}" },
+ "font-family": { "value": "{ams.text.font-family}" },
+ "font-size": { "value": "{ams.text.level.5.font-size}" },
+ "font-weight": { "value": "{ams.text.font-weight.normal}" },
+ "gap": { "value": "{ams.space.inside.lg}" },
+ "line-height": { "value": "{ams.text.level.5.line-height}" },
+ "link": {
+ "color": { "value": "{ams.color.primary-blue}" },
+ "inverse-color": { "value": "{ams.color.primary-white}" },
+ "hover": {
+ "color": { "value": "{ams.color.dark-blue}" }
+ }
+ },
+ "list": {
+ "gap": { "value": "{ams.space.inside.lg}" },
+ "item": {
+ "background-image": {
+ "value": "url(\"data:image/svg+xml;utf8,\")"
+ },
+ "active": {
+ "background-image": {
+ "value": "url(\"data:image/svg+xml;utf8,\")"
+ }
+ }
+ },
+ "list": {
+ "padding-inline-start": { "value": "{ams.space.inside.lg}" }
+ }
+ },
+ "title": {
+ "font-weight": { "value": "{ams.text.font-weight.bold}" },
+ "font-size": { "value": "{ams.text.level.4.font-size}" },
+ "line-height": { "value": "{ams.text.level.4.line-height}" }
+ }
+ }
+ }
+}
diff --git a/storybook/src/components/TableOfContents/TableOfContents.docs.mdx b/storybook/src/components/TableOfContents/TableOfContents.docs.mdx
new file mode 100644
index 0000000000..88eecc44d6
--- /dev/null
+++ b/storybook/src/components/TableOfContents/TableOfContents.docs.mdx
@@ -0,0 +1,11 @@
+import { Controls, Markdown, Meta, Primary } from "@storybook/blocks";
+import * as TableOfContentsStories from "./TableOfContents.stories.tsx";
+import README from "../../../../packages/css/src/components/table-of-contents/README.md?raw";
+
+
+
+{README}
+
+
+
+
diff --git a/storybook/src/components/TableOfContents/TableOfContents.stories.tsx b/storybook/src/components/TableOfContents/TableOfContents.stories.tsx
new file mode 100644
index 0000000000..ab6c9c61b7
--- /dev/null
+++ b/storybook/src/components/TableOfContents/TableOfContents.stories.tsx
@@ -0,0 +1,62 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import { TableOfContents } from '@amsterdam/design-system-react/src'
+import { Meta, StoryObj } from '@storybook/react'
+
+const meta = {
+ title: 'Components/Navigation/Table of Contents',
+ component: TableOfContents,
+ args: {
+ title: 'Op deze pagina',
+ children: [
+ <>
+
+
+
+
+ >,
+ ],
+ },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const MultiLevel: Story = {
+ args: {
+ title: 'Inhoudsopgave',
+ children: [
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >,
+ ],
+ },
+}