diff --git a/packages/css/src/components/index.scss b/packages/css/src/components/index.scss
index de2ceed869..0b5b2636bf 100644
--- a/packages/css/src/components/index.scss
+++ b/packages/css/src/components/index.scss
@@ -4,6 +4,7 @@
*/
/* Append here */
+@import "./password-input/password-input";
@import "./form-error-list/form-error-list";
@import "./table-of-contents/table-of-contents";
@import "./error-message/error-message";
diff --git a/packages/css/src/components/password-input/README.md b/packages/css/src/components/password-input/README.md
new file mode 100644
index 0000000000..afe1f37891
--- /dev/null
+++ b/packages/css/src/components/password-input/README.md
@@ -0,0 +1,29 @@
+
+
+# Password Input
+
+Helps users enter a password.
+
+## Guidelines
+
+- Use this component when the input requires sensitive information, like passwords or PINs.
+ It ensures that the input is not readable by others who might be looking at the screen.
+- The characters entered are hidden, represented by squares.
+
+This component sets `autocapitalize="none"`, `autocorrect="off"` and `spellcheck="false"` to stop browsers automatically changing user input.
+Passwords shouldn’t be checked for spelling or grammar.
+This may also prevent posting the password to third-party plugins.
+These props cannot be overridden.
+
+Consider setting the following attributes:
+
+1. Allow the user’s password manager to automatically fill the password through `autocomplete="current-password"`.
+ When asking for a new password, use `autocomplete="new-password"` instead.
+2. Do not add a `minlength` attribute to ensure passwords meet a minimum length requirement.
+ This would prematurely indicate an error to the user – while they are still typing.
+3. Do not add a `maxlength` attribute either.
+ Users will not get any feedback when their text input has been truncated, e.g. after pasting from a password manager.
+4. If the password is a numeric PIN, add `inputmode="numeric"`.
+ Devices with virtual keyboards then switch to a numeric keypad layout which makes entering the password easier.
+
+Follow the [guidelines for asking for passwords](https://design-system.service.gov.uk/patterns/passwords/) of the GOV.UK Design System.
diff --git a/packages/css/src/components/password-input/password-input.scss b/packages/css/src/components/password-input/password-input.scss
new file mode 100644
index 0000000000..ae2db8daa0
--- /dev/null
+++ b/packages/css/src/components/password-input/password-input.scss
@@ -0,0 +1,59 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+@import "../../common/text-rendering";
+
+@mixin reset {
+ -webkit-appearance: none; // Reset appearance for Safari < 15.4
+ appearance: none; // Reset native appearance, this causes issues on iOS and Android devices
+ border: 0;
+ border-radius: 0; // Reset rounded borders on iOS devices
+ box-sizing: border-box;
+ margin-block: 0;
+}
+
+.ams-password-input {
+ background-color: var(--ams-password-input-background-color);
+ box-shadow: var(--ams-password-input-box-shadow);
+ color: var(--ams-password-input-color);
+ font-family: var(--ams-password-input-font-family);
+ font-size: var(--ams-password-input-font-size);
+ font-weight: var(--ams-password-input-font-weight);
+ inline-size: 100%;
+ line-height: var(--ams-password-input-line-height);
+ outline-offset: var(--ams-password-input-outline-offset);
+ padding-block: var(--ams-password-input-padding-block);
+ padding-inline: var(--ams-password-input-padding-inline);
+ touch-action: manipulation;
+
+ @include text-rendering;
+ @include reset;
+
+ &:hover {
+ box-shadow: var(--ams-password-input-hover-box-shadow);
+ }
+}
+
+.ams-password-input::placeholder {
+ color: var(--ams-password-input-placeholder-color);
+ opacity: 100%; // This resets the lower opacity set by Firefox
+}
+
+.ams-password-input:disabled {
+ background-color: var(--ams-password-input-disabled-background-color);
+ box-shadow: var(--ams-password-input-disabled-box-shadow);
+ color: var(--ams-password-input-disabled-color);
+ cursor: not-allowed;
+}
+
+.ams-password-input:invalid,
+.ams-password-input[aria-invalid="true"] {
+ box-shadow: var(--ams-password-input-invalid-box-shadow);
+
+ &:hover {
+ // TODO: this should be the (currently non-existent) dark red hover color
+ box-shadow: var(--ams-password-input-invalid-hover-box-shadow);
+ }
+}
diff --git a/packages/react/src/PasswordInput/PasswordInput.test.tsx b/packages/react/src/PasswordInput/PasswordInput.test.tsx
new file mode 100644
index 0000000000..d66760b0b0
--- /dev/null
+++ b/packages/react/src/PasswordInput/PasswordInput.test.tsx
@@ -0,0 +1,129 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { createRef, useState } from 'react'
+import { PasswordInput } from './PasswordInput'
+import { Label } from '../Label'
+import '@testing-library/jest-dom'
+
+describe('Password input', () => {
+ 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-password-input')
+ })
+
+ it('renders an additional class name', () => {
+ const { container } = render()
+
+ const component = container.querySelector(':only-child')
+
+ expect(component).toHaveClass('ams-password-input extra')
+ })
+
+ it('renders three attributes for privacy', () => {
+ const { container } = render()
+
+ const component = container.querySelector(':only-child')
+
+ expect(component).toHaveAttribute('autocapitalize', 'none')
+ expect(component).toHaveAttribute('autocorrect', 'off')
+ expect(component).toHaveAttribute('spellcheck', 'false')
+ })
+
+ it('should be working in a controlled state', async () => {
+ function ControlledComponent() {
+ const [value, setValue] = useState('Hello')
+
+ return setValue(e.target.value)} />
+ }
+
+ const { container } = render()
+
+ const componentText = screen.getByDisplayValue('Hello')
+
+ expect(componentText).toBeInTheDocument()
+
+ const component = container.querySelector(':only-child')
+ if (component) {
+ await userEvent.type(component, ', World!')
+ }
+
+ const newComponentText = screen.getByDisplayValue('Hello, World!')
+
+ expect(newComponentText).toBeInTheDocument()
+ })
+
+ it('should not update the value when disabled', async () => {
+ const { container } = render()
+
+ const component = container.querySelector(':only-child')
+ if (component) {
+ await userEvent.type(component, ', World!')
+ }
+
+ expect(component).toHaveValue('Hello')
+ })
+
+ it('supports ForwardRef in React', () => {
+ const ref = createRef()
+
+ const { container } = render()
+
+ const component = container.querySelector(':only-child')
+
+ expect(ref.current).toBe(component)
+ })
+
+ describe('Invalid state', () => {
+ it('is not invalid by default', () => {
+ const { container } = render()
+
+ const component = container.querySelector(':only-child')
+
+ expect(component).not.toBeInvalid()
+ })
+
+ it('can have an invalid state', () => {
+ const { container } = render()
+
+ const component = container.querySelector(':only-child')
+
+ expect(component).toHaveAttribute('aria-invalid')
+ expect(component).toBeInvalid()
+ })
+
+ it('omits non-essential invalid attributes when not invalid', () => {
+ const { container } = render()
+
+ const component = container.querySelector(':only-child')
+
+ expect(component).not.toHaveAttribute('aria-invalid')
+ })
+ })
+
+ describe('Type', () => {
+ it('sets the ‘password’ type', () => {
+ render(
+ <>
+
+
+ >,
+ )
+
+ const component = screen.getByLabelText(/password/i)
+
+ expect(component).toHaveAttribute('type', 'password')
+ })
+ })
+})
diff --git a/packages/react/src/PasswordInput/PasswordInput.tsx b/packages/react/src/PasswordInput/PasswordInput.tsx
new file mode 100644
index 0000000000..3df1d9095c
--- /dev/null
+++ b/packages/react/src/PasswordInput/PasswordInput.tsx
@@ -0,0 +1,34 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import clsx from 'clsx'
+import { forwardRef } from 'react'
+import type { ForwardedRef, InputHTMLAttributes } from 'react'
+
+export type PasswordInputProps = {
+ /** Whether the value fails a validation rule. */
+ invalid?: boolean
+} & Omit<
+ InputHTMLAttributes,
+ 'aria-invalid' | 'autoCapitalize' | 'autoCorrect' | 'spellCheck' | 'type'
+>
+
+export const PasswordInput = forwardRef(
+ ({ className, dir, invalid, ...restProps }: PasswordInputProps, ref: ForwardedRef) => (
+
+ ),
+)
+
+PasswordInput.displayName = 'PasswordInput'
diff --git a/packages/react/src/PasswordInput/README.md b/packages/react/src/PasswordInput/README.md
new file mode 100644
index 0000000000..725d78502d
--- /dev/null
+++ b/packages/react/src/PasswordInput/README.md
@@ -0,0 +1,5 @@
+
+
+# React Password Input component
+
+[Password Input documentation](../../../css/src/components/password-input/README.md)
diff --git a/packages/react/src/PasswordInput/index.ts b/packages/react/src/PasswordInput/index.ts
new file mode 100644
index 0000000000..1d4d6d8a40
--- /dev/null
+++ b/packages/react/src/PasswordInput/index.ts
@@ -0,0 +1,2 @@
+export { PasswordInput } from './PasswordInput'
+export type { PasswordInputProps } from './PasswordInput'
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index d1e4be314f..f616032c4e 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -4,6 +4,7 @@
*/
/* Append here */
+export * from './PasswordInput'
export * from './FormErrorList'
export * from './TableOfContents'
export * from './ErrorMessage'
diff --git a/proprietary/tokens/src/components/ams/password-input.tokens.json b/proprietary/tokens/src/components/ams/password-input.tokens.json
new file mode 100644
index 0000000000..1b659b4cfd
--- /dev/null
+++ b/proprietary/tokens/src/components/ams/password-input.tokens.json
@@ -0,0 +1,33 @@
+{
+ "ams": {
+ "password-input": {
+ "background-color": { "value": "{ams.color.primary-white}" },
+ "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.sm}" },
+ "padding-inline": { "value": "{ams.space.md}" },
+ "disabled": {
+ "background-color": { "value": "{ams.color.primary-white}" },
+ "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}" }
+ }
+ },
+ "placeholder": {
+ "color": { "value": "{ams.color.neutral-grey3}" }
+ }
+ }
+ }
+}
diff --git a/storybook/src/components/PasswordInput/PasswordInput.docs.mdx b/storybook/src/components/PasswordInput/PasswordInput.docs.mdx
new file mode 100644
index 0000000000..b06271db43
--- /dev/null
+++ b/storybook/src/components/PasswordInput/PasswordInput.docs.mdx
@@ -0,0 +1,13 @@
+{/* @license CC0-1.0 */}
+
+import { Controls, Markdown, Meta, Primary } from "@storybook/blocks";
+import * as PasswordInputStories from "./PasswordInput.stories.tsx";
+import README from "../../../../packages/css/src/components/password-input/README.md?raw";
+
+
+
+{README}
+
+
+
+
diff --git a/storybook/src/components/PasswordInput/PasswordInput.stories.tsx b/storybook/src/components/PasswordInput/PasswordInput.stories.tsx
new file mode 100644
index 0000000000..a63801c366
--- /dev/null
+++ b/storybook/src/components/PasswordInput/PasswordInput.stories.tsx
@@ -0,0 +1,27 @@
+/**
+ * @license EUPL-1.2+
+ * Copyright Gemeente Amsterdam
+ */
+
+import { PasswordInput } from '@amsterdam/design-system-react/src'
+import { Meta, StoryObj } from '@storybook/react'
+
+const meta = {
+ title: 'Components/Forms/Password Input',
+ component: PasswordInput,
+ args: {
+ disabled: false,
+ invalid: false,
+ },
+ argTypes: {
+ disabled: {
+ description: 'Prevents interaction. Avoid if possible.',
+ },
+ },
+} satisfies Meta
+
+export default meta
+
+type Story = StoryObj
+
+export const Default: Story = {}