diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss
index 0ca1ff3e8e..063a49713e 100644
--- a/packages/css/src/components/index.scss
+++ b/packages/css/src/components/index.scss
@@ -4,6 +4,7 @@
*/
/* Append here */
+@import "./select/select";
@import "./time-input/time-input";
@import "./date-input/date-input";
@import "./document/document";
diff --git a/packages/css/src/components/select/README.md b/packages/css/src/components/select/README.md
new file mode 100644
index 0000000000..5b09daf963
--- /dev/null
+++ b/packages/css/src/components/select/README.md
@@ -0,0 +1,9 @@
+
+
+# Select
+
+A form control that allows users to select one or more options from a list.
+
+## References
+
+- [MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select)
diff --git a/packages/css/src/components/select/select.scss b/packages/css/src/components/select/select.scss
new file mode 100644
index 0000000000..72db3c7e95
--- /dev/null
+++ b/packages/css/src/components/select/select.scss
@@ -0,0 +1,60 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+@mixin reset {
+ appearance: none;
+ border: 0;
+ border-radius: 0; // Reset rounded borders for Safari on MacOS
+}
+
+.ams-select {
+ background-color: var(--ams-select-background-color);
+ box-shadow: var(--ams-select-box-shadow);
+ color: var(--ams-select-color);
+ font-family: var(--ams-select-font-family);
+ font-size: var(--ams-select-font-size);
+ font-weight: var(--ams-select-font-weight);
+ line-height: var(--ams-select-line-height);
+ max-inline-size: 100%;
+ outline-offset: var(--ams-select-outline-offset);
+ padding-block: var(--ams-select-padding-block);
+ padding-inline: var(--ams-select-padding-inline);
+ touch-action: manipulation;
+
+ &:not([multiple]) {
+ background-image: var(--ams-select-background-image);
+ background-position: var(--ams-select-background-position);
+ background-repeat: no-repeat;
+ background-size: 1em 1em;
+ }
+
+ &:hover {
+ box-shadow: var(--ams-select-hover-box-shadow);
+ }
+
+ @include reset;
+}
+
+.ams-select[aria-invalid="true"] {
+ box-shadow: var(--ams-select-invalid-box-shadow);
+
+ &:hover {
+ box-shadow: var(--ams-select-invalid-hover-box-shadow);
+ }
+}
+
+.ams-select:disabled {
+ box-shadow: var(--ams-select-disabled-box-shadow);
+ color: var(--ams-select-disabled-color);
+ cursor: not-allowed;
+
+ &:not([multiple]) {
+ background-image: var(--ams-select-disabled-background-image);
+ }
+}
+
+.ams-select__option:disabled {
+ color: var(--ams-select-option-disabled-color);
+}
diff --git a/packages/react/src/Select/README.md b/packages/react/src/Select/README.md
new file mode 100644
index 0000000000..f830e9d3d1
--- /dev/null
+++ b/packages/react/src/Select/README.md
@@ -0,0 +1,5 @@
+
+
+# React Select component
+
+[Select documentation](../../../css/src/components/select/README.md)
diff --git a/packages/react/src/Select/Select.test.tsx b/packages/react/src/Select/Select.test.tsx
new file mode 100644
index 0000000000..d3e0902b43
--- /dev/null
+++ b/packages/react/src/Select/Select.test.tsx
@@ -0,0 +1,90 @@
+import { render, screen } from '@testing-library/react'
+import { createRef } from 'react'
+import { Select } from './Select'
+import '@testing-library/jest-dom'
+
+describe('Select', () => {
+ it('renders', () => {
+ render()
+
+ const component = screen.getByRole('combobox')
+
+ expect(component).toBeInTheDocument()
+ expect(component).toBeVisible()
+ })
+
+ it('renders a design system BEM class name', () => {
+ render()
+
+ const component = screen.getByRole('combobox')
+
+ expect(component).toHaveClass('ams-select')
+ })
+
+ it('renders an additional class name', () => {
+ render()
+
+ const component = screen.getByRole('combobox')
+
+ expect(component).toHaveClass('ams-select extra')
+ })
+
+ it('supports ForwardRef in React', () => {
+ const ref = createRef()
+
+ render()
+
+ const component = screen.getByRole('combobox')
+
+ expect(ref.current).toBe(component)
+ })
+
+ it('renders options', () => {
+ render(
+ ,
+ )
+
+ const select = screen.getByRole('combobox')
+
+ const option = screen.getByRole('option', {
+ name: 'Option B',
+ })
+
+ expect(select).toContain(option)
+ })
+
+ it('can be disabled', () => {
+ render()
+
+ const component = screen.getByRole('combobox')
+
+ expect(component).toBeDisabled()
+ })
+
+ it('can be invalid', () => {
+ render()
+
+ const component = screen.getByRole('combobox')
+
+ expect(component).toHaveClass('ams-select--invalid')
+ })
+
+ it('is not required by default', () => {
+ render()
+
+ const component = screen.getByRole('combobox')
+
+ expect(component).not.toBeRequired()
+ })
+
+ it('omits the required attribute when not required', () => {
+ render()
+
+ const component = screen.getByRole('combobox')
+
+ expect(component).not.toHaveAttribute('required')
+ })
+})
diff --git a/packages/react/src/Select/Select.tsx b/packages/react/src/Select/Select.tsx
new file mode 100644
index 0000000000..5dd90c63f1
--- /dev/null
+++ b/packages/react/src/Select/Select.tsx
@@ -0,0 +1,32 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import clsx from 'clsx'
+import { forwardRef } from 'react'
+import type { ForwardedRef, PropsWithChildren, SelectHTMLAttributes } from 'react'
+import { SelectOption } from './SelectOption'
+import { SelectOptionGroup } from './SelectOptionGroup'
+
+export type SelectProps = {
+ /** There is no native invalid attribute for select, but you can use this to get the same result as other form components */
+ invalid?: boolean
+} & PropsWithChildren>
+
+const SelectRoot = forwardRef(
+ ({ children, className, invalid, ...restProps }: SelectProps, ref: ForwardedRef) => (
+
+ ),
+)
+
+SelectRoot.displayName = 'Select'
+
+export const Select = Object.assign(SelectRoot, { Option: SelectOption, Group: SelectOptionGroup })
diff --git a/packages/react/src/Select/SelectOption.test.tsx b/packages/react/src/Select/SelectOption.test.tsx
new file mode 100644
index 0000000000..7a9db8a5c2
--- /dev/null
+++ b/packages/react/src/Select/SelectOption.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from '@testing-library/react'
+import { createRef } from 'react'
+import { Select } from './Select'
+import '@testing-library/jest-dom'
+
+describe('Select option', () => {
+ it('renders', () => {
+ render()
+
+ const component = screen.getByRole('option')
+
+ expect(component).toBeInTheDocument()
+ expect(component).toBeVisible()
+ })
+
+ it('renders an option role element with a text label', () => {
+ render(Option)
+
+ const option = screen.getByRole('option', {
+ name: 'Option',
+ })
+
+ expect(option).toBeInTheDocument()
+ })
+
+ it('renders a design system BEM class name', () => {
+ render()
+
+ const component = screen.getByRole('option')
+
+ expect(component).toHaveClass('ams-select__option')
+ })
+
+ it('renders an additional class name', () => {
+ render()
+
+ const component = screen.getByRole('option')
+
+ expect(component).toHaveClass('ams-select__option extra')
+ })
+
+ it('supports ForwardRef in React', () => {
+ const ref = createRef()
+
+ render()
+
+ const component = screen.getByRole('option')
+
+ expect(ref.current).toBe(component)
+ })
+
+ it('can be disabled', () => {
+ render()
+
+ const component = screen.getByRole('option')
+
+ expect(component).toBeDisabled()
+ })
+})
diff --git a/packages/react/src/Select/SelectOption.tsx b/packages/react/src/Select/SelectOption.tsx
new file mode 100644
index 0000000000..70cc217dcb
--- /dev/null
+++ b/packages/react/src/Select/SelectOption.tsx
@@ -0,0 +1,23 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import clsx from 'clsx'
+import { forwardRef } from 'react'
+import type { ForwardedRef, OptionHTMLAttributes, PropsWithChildren } from 'react'
+
+export type SelectOptionProps = OptionHTMLAttributes
+
+export const SelectOption = forwardRef(
+ (
+ { children, className, ...restProps }: PropsWithChildren,
+ ref: ForwardedRef,
+ ) => (
+
+ ),
+)
+
+SelectOption.displayName = 'Select.Option'
diff --git a/packages/react/src/Select/SelectOptionGroup.test.tsx b/packages/react/src/Select/SelectOptionGroup.test.tsx
new file mode 100644
index 0000000000..7f6d3d287c
--- /dev/null
+++ b/packages/react/src/Select/SelectOptionGroup.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from '@testing-library/react'
+import { createRef } from 'react'
+import { Select } from './Select'
+import '@testing-library/jest-dom'
+
+describe('Select option group', () => {
+ it('renders', () => {
+ render()
+
+ const component = screen.getByRole('group')
+
+ expect(component).toBeInTheDocument()
+ expect(component).toBeVisible()
+ })
+
+ it('renders an group role element with a text label', () => {
+ render()
+
+ const option = screen.getByRole('group', {
+ name: 'Options',
+ })
+
+ expect(option).toBeInTheDocument()
+ })
+
+ it('renders a design system BEM class name', () => {
+ render()
+
+ const component = screen.getByRole('group')
+
+ expect(component).toHaveClass('ams-select__group')
+ })
+
+ it('renders an additional class name', () => {
+ render()
+
+ const component = screen.getByRole('group')
+
+ expect(component).toHaveClass('ams-select__group extra')
+ })
+
+ it('supports ForwardRef in React', () => {
+ const ref = createRef()
+
+ render()
+
+ const component = screen.getByRole('group')
+
+ expect(ref.current).toBe(component)
+ })
+
+ it('can be disabled', () => {
+ render()
+
+ const component = screen.getByRole('group')
+
+ expect(component).toBeDisabled()
+ })
+})
diff --git a/packages/react/src/Select/SelectOptionGroup.tsx b/packages/react/src/Select/SelectOptionGroup.tsx
new file mode 100644
index 0000000000..7d0afd105f
--- /dev/null
+++ b/packages/react/src/Select/SelectOptionGroup.tsx
@@ -0,0 +1,23 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import clsx from 'clsx'
+import { forwardRef } from 'react'
+import type { ForwardedRef, OptgroupHTMLAttributes, PropsWithChildren } from 'react'
+
+export type SelectOptionGroupProps = OptgroupHTMLAttributes
+
+export const SelectOptionGroup = forwardRef(
+ (
+ { children, className, ...restProps }: PropsWithChildren,
+ ref: ForwardedRef,
+ ) => (
+
+ ),
+)
+
+SelectOptionGroup.displayName = 'Select.Group'
diff --git a/packages/react/src/Select/index.ts b/packages/react/src/Select/index.ts
new file mode 100644
index 0000000000..7fb657c440
--- /dev/null
+++ b/packages/react/src/Select/index.ts
@@ -0,0 +1,3 @@
+export { Select } from './Select'
+export type { SelectProps } from './Select'
+export type { SelectOptionProps } from './SelectOption'
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index fbdc4c7857..70b5d5f6da 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -4,6 +4,7 @@
*/
/* Append here */
+export * from './Select'
export * from './TimeInput'
export * from './DateInput'
export * from './Avatar'
diff --git a/proprietary/tokens/src/components/ams/select.tokens.json b/proprietary/tokens/src/components/ams/select.tokens.json
new file mode 100644
index 0000000000..ad865ff720
--- /dev/null
+++ b/proprietary/tokens/src/components/ams/select.tokens.json
@@ -0,0 +1,41 @@
+{
+ "ams": {
+ "select": {
+ "background-color": { "value": "{ams.color.primary-white}" },
+ "background-image": {
+ "value": "url(\"data:image/svg+xml;utf8,\")"
+ },
+ "background-position": { "value": "right {ams.space.inside.md} center" },
+ "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.primary-black}" },
+ "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}" },
+ "line-height": { "value": "{ams.text.level.5.line-height}" },
+ "outline-offset": { "value": "{ams.focus.outline-offset}" },
+ "padding-block": { "value": "{ams.space.inside.xs}" },
+ "padding-inline": { "value": "{ams.space.inside.md} calc(2 * {ams.space.inside.md} + 1em)" },
+ "disabled": {
+ "background-image": {
+ "value": "url(\"data:image/svg+xml;utf8,\")"
+ },
+ "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.neutral-grey2}" },
+ "color": { "value": "{ams.color.neutral-grey2}" }
+ },
+ "hover": {
+ "box-shadow": { "value": "inset 0 0 0 {ams.border.width.md} {ams.color.primary-black}" }
+ },
+ "invalid": {
+ "box-shadow": { "value": "inset 0 0 0 {ams.border.width.sm} {ams.color.primary-red}" },
+ "hover": {
+ "box-shadow": { "value": "inset 0 0 0 {ams.border.width.md} {ams.color.primary-red}" }
+ }
+ },
+ "option": {
+ "disabled": {
+ "color": { "value": "{ams.color.neutral-grey2}" }
+ }
+ }
+ }
+ }
+}
diff --git a/storybook/src/components/Select/Select.docs.mdx b/storybook/src/components/Select/Select.docs.mdx
new file mode 100644
index 0000000000..1c9141e937
--- /dev/null
+++ b/storybook/src/components/Select/Select.docs.mdx
@@ -0,0 +1,34 @@
+import { Canvas, Controls, Markdown, Meta, Primary } from "@storybook/blocks";
+import * as SelectStories from "./Select.stories.tsx";
+import README from "../../../../packages/css/src/components/select/README.md?raw";
+
+
+
+{README}
+
+# Default
+
+
+
+
+
+## Multiple
+
+Avoid adding functionality to allow selecting multiple options. Multi select is harder to use
+on desktop as they require the user to hold down the `Ctrl` or `Cmd` key while clicking on the options.
+It is recommended to use checkboxes instead.
+
+## Grouped
+
+Use the `Select.Group` component to group options.
+The component requires a `label` attribute.
+
+
+
+## Invalid
+
+
+
+## Disabled
+
+
diff --git a/storybook/src/components/Select/Select.stories.tsx b/storybook/src/components/Select/Select.stories.tsx
new file mode 100644
index 0000000000..fddfb3d0cc
--- /dev/null
+++ b/storybook/src/components/Select/Select.stories.tsx
@@ -0,0 +1,108 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import { Select } from '@amsterdam/design-system-react'
+import { Meta, StoryObj } from '@storybook/react'
+
+const meta = {
+ title: 'Components/Forms/Select',
+ component: Select,
+ args: {
+ invalid: false,
+ disabled: false,
+ children: [
+
+ Centrum
+ ,
+
+ Noord
+ ,
+
+ West
+ ,
+
+ Westpoort
+ ,
+
+ Nieuw-West
+ ,
+
+ Zuid
+ ,
+
+ Zuidoost
+ ,
+ ],
+ },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const Grouped: Story = {
+ args: {
+ children: [
+
+ BG-terrein e.o.
+ Burgwallen oost
+ Kop Zeedijk
+ Nes e.o.
+ Oude Kerk e.o.
+ ,
+
+ Begijnhofbuurt
+ Hemelrijk
+ Kalverdriehoek
+ Nieuwe Kerk e.o.
+ Nieuwendijk Noord
+ Spuistraat Noord
+ Spuistraat Zuid
+ Stationsplein e.o.
+ ,
+
+ Felix Meritisbuurt
+ Langestraat e.o.
+ Leidsegracht Noord
+ Leliegracht e.o.
+ ,
+
+ Haarlemmerbuurt Oost
+ Haarlemmerbuurt West
+ Planciusbuurt Noord
+ Planciusbuurt Zuid
+ Westelijke eilanden
+ Westerdokseiland
+ ,
+
+ Anjeliersbuurt Noord
+ Anjeliersbuurt Zuid
+ Bloemgrachtbuurt
+ Driehoekbuurt
+ Elandsgrachtbuurt
+ Groenmarktkadebuurt
+ Marnixbuurt Midden
+ Marnixbuurt Noord
+ Marnixbuurt Zuid
+ Passeerdersgrachtbuurt
+ Zaagpoortbuurt
+ ,
+ ],
+ },
+}
+
+export const Invalid: Story = {
+ args: {
+ invalid: true,
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ },
+}