diff --git a/packages/css/src/components/figure/README.md b/packages/css/src/components/figure/README.md
new file mode 100644
index 0000000000..a17fbad134
--- /dev/null
+++ b/packages/css/src/components/figure/README.md
@@ -0,0 +1,5 @@
+
+
+# Figure
+
+Groups media content with a caption that describes it.
diff --git a/packages/css/src/components/figure/figure.scss b/packages/css/src/components/figure/figure.scss
new file mode 100644
index 0000000000..8d9d61f373
--- /dev/null
+++ b/packages/css/src/components/figure/figure.scss
@@ -0,0 +1,33 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+@use "../../common/text-rendering" as *;
+
+@mixin reset-figure {
+ margin-block: 0;
+ margin-inline: 0;
+}
+
+.ams-figure {
+ display: flex;
+ flex-direction: column;
+ gap: var(--ams-figure-gap);
+
+ @include reset-figure;
+}
+
+.ams-figure__caption {
+ color: var(--ams-figure-caption-color);
+ font-family: var(--ams-figure-caption-font-family);
+ font-size: var(--ams-figure-caption-font-size);
+ font-weight: var(--ams-figure-caption-font-weight);
+ line-height: var(--ams-figure-caption-line-height);
+
+ @include text-rendering;
+}
+
+.ams-figure__caption--inverse-color {
+ color: var(--ams-figure-caption-inverse-color);
+}
diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss
index 3fb749ce8c..d2c301d2cc 100644
--- a/packages/css/src/components/index.scss
+++ b/packages/css/src/components/index.scss
@@ -25,6 +25,7 @@
@use "error-message/error-message";
@use "field-set/field-set";
@use "field/field";
+@use "figure/figure";
@use "file-input/file-input";
@use "file-list/file-list";
@use "footer/footer";
diff --git a/packages/react/src/Figure/Figure.test.tsx b/packages/react/src/Figure/Figure.test.tsx
new file mode 100644
index 0000000000..af3bac9cba
--- /dev/null
+++ b/packages/react/src/Figure/Figure.test.tsx
@@ -0,0 +1,41 @@
+import { render, screen } from '@testing-library/react'
+import { createRef } from 'react'
+import { Figure } from './Figure'
+import '@testing-library/jest-dom'
+
+describe('Figure', () => {
+ it('renders', () => {
+ render()
+
+ const component = screen.getByRole('figure')
+
+ expect(component).toBeInTheDocument()
+ expect(component).toBeVisible()
+ })
+
+ it('renders a design system BEM class name', () => {
+ render()
+
+ const component = screen.getByRole('figure')
+
+ expect(component).toHaveClass('ams-figure')
+ })
+
+ it('renders an additional class name', () => {
+ render()
+
+ const component = screen.getByRole('figure')
+
+ expect(component).toHaveClass('ams-figure extra')
+ })
+
+ it('supports ForwardRef in React', () => {
+ const ref = createRef()
+
+ render()
+
+ const component = screen.getByRole('figure')
+
+ expect(ref.current).toBe(component)
+ })
+})
diff --git a/packages/react/src/Figure/Figure.tsx b/packages/react/src/Figure/Figure.tsx
new file mode 100644
index 0000000000..d7867558c9
--- /dev/null
+++ b/packages/react/src/Figure/Figure.tsx
@@ -0,0 +1,21 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import clsx from 'clsx'
+import { forwardRef } from 'react'
+import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react'
+import { FigureCaption } from './FigureCaption'
+
+export type FigureProps = PropsWithChildren>
+
+const FigureRoot = forwardRef(({ children, className, ...restProps }: FigureProps, ref: ForwardedRef) => (
+
+))
+
+FigureRoot.displayName = 'Figure'
+
+export const Figure = Object.assign(FigureRoot, { Caption: FigureCaption })
diff --git a/packages/react/src/Figure/FigureCaption.test.tsx b/packages/react/src/Figure/FigureCaption.test.tsx
new file mode 100644
index 0000000000..152a1d952b
--- /dev/null
+++ b/packages/react/src/Figure/FigureCaption.test.tsx
@@ -0,0 +1,49 @@
+import { render } from '@testing-library/react'
+import { createRef } from 'react'
+import { FigureCaption } from './FigureCaption'
+import '@testing-library/jest-dom'
+
+describe('Figure Caption', () => {
+ 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-figure__caption')
+ })
+
+ it('renders the right inverse color class', () => {
+ const { container } = render(Caption)
+
+ const component = container.querySelector(':only-child')
+
+ expect(component).toHaveClass('ams-figure__caption--inverse-color')
+ })
+
+ it('renders an additional class name', () => {
+ const { container } = render()
+
+ const component = container.querySelector(':only-child')
+
+ expect(component).toHaveClass('ams-figure__caption 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/Figure/FigureCaption.tsx b/packages/react/src/Figure/FigureCaption.tsx
new file mode 100644
index 0000000000..1f967fba49
--- /dev/null
+++ b/packages/react/src/Figure/FigureCaption.tsx
@@ -0,0 +1,27 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import clsx from 'clsx'
+import { forwardRef } from 'react'
+import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react'
+
+export type FigureCaptionProps = {
+ /** Changes the text colour for readability on a dark background. */
+ inverseColor?: boolean
+} & PropsWithChildren>
+
+export const FigureCaption = forwardRef(
+ ({ children, className, inverseColor, ...restProps }: FigureCaptionProps, ref: ForwardedRef) => (
+
+ {children}
+
+ ),
+)
+
+FigureCaption.displayName = 'Figure.Caption'
diff --git a/packages/react/src/Figure/README.md b/packages/react/src/Figure/README.md
new file mode 100644
index 0000000000..90d22b1942
--- /dev/null
+++ b/packages/react/src/Figure/README.md
@@ -0,0 +1,5 @@
+
+
+# React Figure component
+
+[Figure documentation](../../../css/src/components/figure/README.md)
diff --git a/packages/react/src/Figure/index.ts b/packages/react/src/Figure/index.ts
new file mode 100644
index 0000000000..c6b99f171e
--- /dev/null
+++ b/packages/react/src/Figure/index.ts
@@ -0,0 +1,2 @@
+export { Figure } from './Figure'
+export type { FigureProps } from './Figure'
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 34dc74a18b..fc0f152621 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -23,6 +23,7 @@ export * from './Dialog'
export * from './ErrorMessage'
export * from './Field'
export * from './FieldSet'
+export * from './Figure'
export * from './FileInput'
export * from './FileList'
export * from './Footer'
diff --git a/proprietary/tokens/src/components/ams/figure.tokens.json b/proprietary/tokens/src/components/ams/figure.tokens.json
new file mode 100644
index 0000000000..8c6b1b15a5
--- /dev/null
+++ b/proprietary/tokens/src/components/ams/figure.tokens.json
@@ -0,0 +1,15 @@
+{
+ "ams": {
+ "figure": {
+ "gap": { "value": "{ams.space.sm}" },
+ "caption": {
+ "color": { "value": "{ams.brand.color.neutral.100}" },
+ "font-family": { "value": "{ams.text.font-family}" },
+ "font-size": { "value": "{ams.text.level.6.font-size}" },
+ "font-weight": { "value": "{ams.text.font-weight.normal}" },
+ "line-height": { "value": "{ams.text.level.6.line-height}" },
+ "inverse-color": { "value": "{ams.brand.color.neutral.0}" }
+ }
+ }
+ }
+}
diff --git a/storybook/src/components/Figure/Figure.docs.mdx b/storybook/src/components/Figure/Figure.docs.mdx
new file mode 100644
index 0000000000..57e9c01722
--- /dev/null
+++ b/storybook/src/components/Figure/Figure.docs.mdx
@@ -0,0 +1,20 @@
+{/* @license CC0-1.0 */}
+
+import { Canvas, Markdown, Meta, Primary } from "@storybook/blocks";
+import * as FigureStories from "./Figure.stories.tsx";
+import README from "../../../../packages/css/src/components/figure/README.md?raw";
+
+
+
+{README}
+
+
+
+## Examples
+
+### Inverse colour
+
+Set the `inverseColor` prop if the Figure Caption sits on a dark background.
+This ensures the colour of the text provides enough contrast.
+
+
diff --git a/storybook/src/components/Figure/Figure.stories.tsx b/storybook/src/components/Figure/Figure.stories.tsx
new file mode 100644
index 0000000000..ebc56dc328
--- /dev/null
+++ b/storybook/src/components/Figure/Figure.stories.tsx
@@ -0,0 +1,45 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import { Image } from '@amsterdam/design-system-react'
+import { Figure } from '@amsterdam/design-system-react/src'
+import { Meta, StoryObj } from '@storybook/react'
+import { exampleCaption } from '../shared/exampleContent'
+
+const caption = exampleCaption()
+
+const meta = {
+ title: 'Components/Media/Figure',
+ component: Figure,
+ args: {
+ children: caption,
+ inverseColor: false,
+ },
+ render: ({ children, ...args }) => (
+
+ ),
+} satisfies Meta
+// We use the Caption type here to allow inverseColor. This works as long as Figure has no props of its own.
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const InverseColour: Story = {
+ args: {
+ inverseColor: true,
+ },
+}
diff --git a/storybook/src/components/shared/exampleContent.ts b/storybook/src/components/shared/exampleContent.ts
index b53be2bb3e..c251506d2e 100644
--- a/storybook/src/components/shared/exampleContent.ts
+++ b/storybook/src/components/shared/exampleContent.ts
@@ -17,6 +17,19 @@ export const exampleAccordionHeading = () =>
'Voorgaande versies van ramingen',
])
+export const exampleCaption = () =>
+ pickRandomContent([
+ 'Een rustige Amsterdamse gracht met eeuwenoude gevels die weerspiegelen in het water, terwijl fietsen nonchalant tegen de brugleuning rusten – een alledaags tafereel vol historie en charme. Foto: Liam Dekker.',
+ 'Een rij geparkeerde fietsen langs een smalle gracht met klassieke Amsterdamse gevels op de achtergrond.',
+ 'Een klein houten bootje dobbert rustig op het water, omringd door bomen en bakstenen panden met grote ramen. Foto: Sophie van der Brugge.',
+ 'Een typische Amsterdamse brug met smeedijzeren leuningen, vol met fietsen en uitzicht op een grachtenpand met een klokgevel.',
+ 'Een stille gracht met weerspiegelende gevels, terwijl een tram in de verte over een brug rijdt. Foto: Isabel Groeneveld.',
+ 'Een zonovergoten terras aan de gracht, met stoelen op de kade en uitzicht op een sierlijke ophaalbrug.',
+ 'Een grachtenpand met vrolijke bloemenbakken op de vensterbanken en een smalle trap naar de voordeur. Foto: Joris Zandvoort.',
+ 'Een schuin geplaatste fiets tegen een lantaarnpaal, met op de achtergrond een karakteristiek houten bruggetje.',
+ 'Een groep Ajax-supporters in rood-witte sjaals verzamelt zich op een plein, klaar voor een wedstrijd in de Johan Cruijff ArenA. Foto: Louis Flitskamp.',
+ ])
+
export const exampleHeading = () =>
pickRandomContent([
'Meer plekken voor kunst en cultuur, verspreid over de stad',
diff --git a/storybook/src/styles/overrides.css b/storybook/src/styles/overrides.css
index 5e635314ab..e757ca4a53 100644
--- a/storybook/src/styles/overrides.css
+++ b/storybook/src/styles/overrides.css
@@ -10,7 +10,7 @@
.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > :is(ol, ul) li,
.sbdocs-content.sbdocs-content > table:not(.sb-unstyled) :is(td, th),
.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > table:not(.sb-unstyled) :is(td, th),
-.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) figcaption {
+.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > figure > figcaption {
color: #000;
font-family: "Amsterdam Sans", "Arial", sans-serif;
}
@@ -110,7 +110,7 @@
font-size: 1rem;
}
-.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) figcaption {
+.sbdocs-content.sbdocs-content > div:not(.sb-unstyled) > figure > figcaption {
font-size: 0.875rem;
}