Repository: radix-ui/primitives
Pull request #3459 (2025-04-10 23:14:40)

checkbox: Add unstable `Provider`, `Trigger` and `BubbleInput`
This PR introduces new parts for the Checkbox component. This change is intended to test out a new pattern that we can replicate for other form controls.

Currently our form control components will try to detect whether or not they are associated with a form, and if so, render a hidden input so that native events bubble to the form itself. While this can make behavior feel more like native form elements, the downside is that it adds an additional element that is hard to control and not always what users expect.

The goal is the hand control back to the user. So instead of `Checkbox.Root` rendering both a provider and an input, users can opt in and render the parts that they want.

```tsx
// before
<Checkbox.Root>
  <Checkbox.Indicator />
</Checkbox.Root>

// after
<Checkbox.Provider>
  <Checkbox.Trigger>
    <Checkbox.Indicator />
  </Checkbox.Trigger>
</Checkbox.Provider>
```

Currently these parts are prefixed with `unstable_` since we may want to consider the API more deeply before committing to these patterns. Should we decide to move forward, in a future version we'd probably rename `Checkbox.Provider` to `Checkbox.Root` and get rid of the pre-composed version entirely.

Related:

- #3365
- #874
- #3356
- #1333
- #2987

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: b529b560b059652f7f9238e2e3388fb213cb617d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/radix-ui/primitives/new/chance/split-checkbox-components?filename=.changeset/lazy-bees-complain.md&value=---%0A%22%40repo%2Fstorybook%22%3A%20patch%0A%22%40radix-ui%2Freact-checkbox%22%3A%20patch%0A---%0A%0Acheckbox%3A%20Add%20unstable%20%60Provider%60%2C%20%60Trigger%60%20and%20%60BubbleInput%60%0A)



Patch:
diff --git a/.changeset/heavy-rockets-allow.md b/.changeset/heavy-rockets-allow.md
new file mode 100644
index 00000000..642be586
--- /dev/null
+++ b/.changeset/heavy-rockets-allow.md
@@ -0,0 +1,6 @@
+---
+'@radix-ui/react-checkbox': minor
+'radix-ui': minor
+---
+
+Add unstable `Provider`, `Trigger` and `BubbleInput` parts to Checkbox (#3459)
diff --git a/.changeset/wet-otters-hang.md b/.changeset/wet-otters-hang.md
new file mode 100644
index 00000000..af1aeaf0
--- /dev/null
+++ b/.changeset/wet-otters-hang.md
@@ -0,0 +1,41 @@
+---
+'@radix-ui/react-password-toggle-field': minor
+'radix-ui': minor
+---
+
+Introduce new Password Toggle Field primitive
+
+This new primitive provides components for rendering a password input alongside a button to toggle its visibility. Aside from its primary functionality, it also includes:
+
+- Returning focus to the input when toggling with a pointer
+- Maintaining focus when toggling with keyboard or virtual navigation
+- Resetting visibility to hidden after form submission to prevent accidental storage
+- Implicit accessible labeling for icon-based toggle buttons
+
+This API is currently unstable, and we hope you'll help us test it out! Import the primitive using the `unstable_` prefix.
+
+```tsx
+import { unstable_PasswordToggleField as PasswordToggleField } from 'radix-ui';
+
+function FieldWithIconToggle() {
+  return (
+    <PasswordToggleField.Root>
+      <PasswordToggleField.Input />
+      <PasswordToggleField.Toggle>
+        <PasswordToggleField.Icon visible={<EyeOpenIcon />} hidden={<EyeClosedIcon />} />
+      </PasswordToggleField.Toggle>
+    </PasswordToggleField.Root>
+  );
+}
+
+function FieldWithTextToggle() {
+  return (
+    <PasswordToggleField.Root>
+      <PasswordToggleField.Input />
+      <PasswordToggleField.Toggle>
+        <PasswordToggleField.Slot visible="Hide password" hidden="Show password" />
+      </PasswordToggleField.Toggle>
+    </PasswordToggleField.Root>
+  );
+}
+```
diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml
index af831d53..33e8ecb4 100644
--- a/.github/workflows/publish-snapshot.yml
+++ b/.github/workflows/publish-snapshot.yml
@@ -8,6 +8,9 @@ on:
       # - '.github/**'
       - '**/package.json'
 
+env:
+  NPM_TOKEN: ${{ secrets.NPM_AUTOMATION_FROM_CHANCE }}
+
 concurrency: ${{ github.workflow }}-${{ github.ref }}
 
 jobs:
@@ -50,5 +53,3 @@ jobs:
 
       - name: Publish snapshot
         run: pnpm run release:next
-        env:
-          NPM_TOKEN: ${{ secrets.NPM_AUTOMATION_FROM_CHANCE }}
diff --git a/.github/workflows/publish-stable.yml b/.github/workflows/publish-stable.yml
index be81bffd..d6f6eaee 100644
--- a/.github/workflows/publish-stable.yml
+++ b/.github/workflows/publish-stable.yml
@@ -5,6 +5,10 @@ on:
     branches:
       - stable
 
+env:
+  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+  NPM_TOKEN: ${{ secrets.NPM_AUTOMATION_FROM_CHANCE }}
+
 concurrency: ${{ github.workflow }}-${{ github.ref }}
 
 jobs:
@@ -46,6 +50,3 @@ jobs:
           version: pnpm run bump:stable
           publish: pnpm run release:stable:ci
           createGithubReleases: false
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          NPM_TOKEN: ${{ secrets.NPM_AUTOMATION_FROM_CHANCE }}
diff --git a/apps/storybook/package.json b/apps/storybook/package.json
index 26b3d4c4..b68d028c 100644
--- a/apps/storybook/package.json
+++ b/apps/storybook/package.json
@@ -27,6 +27,7 @@
     "@storybook/addon-webpack5-compiler-swc": "^2.0.0",
     "@storybook/blocks": "^8.6.12",
     "@storybook/experimental-addon-test": "^8.6.12",
+    "@storybook/preview-api": "^8.6.12",
     "@storybook/react": "^8.6.12",
     "@storybook/react-webpack5": "^8.6.12",
     "@storybook/manager-api": "^8.6.12",
diff --git a/apps/storybook/stories/checkbox.stories.tsx b/apps/storybook/stories/checkbox.stories.tsx
index e280a754..103e6a65 100644
--- a/apps/storybook/stories/checkbox.stories.tsx
+++ b/apps/storybook/stories/checkbox.stories.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable react/jsx-pascal-case */
 import * as React from 'react';
 import { Checkbox, Label as LabelPrimitive } from 'radix-ui';
 import styles from './checkbox.stories.module.css';
@@ -5,6 +6,194 @@ import styles from './checkbox.stories.module.css';
 export default { title: 'Components/Checkbox' };
 
 export const Styled = () => (
+  <>
+    <p>This checkbox is nested inside a label. The state is uncontrolled.</p>
+
+    <h1>Custom label</h1>
+    <Label>
+      Label{' '}
+      <Checkbox.unstable_Provider>
+        <Checkbox.unstable_Trigger className={styles.root}>
+          <Checkbox.Indicator className={styles.indicator} />
+        </Checkbox.unstable_Trigger>
+      </Checkbox.unstable_Provider>
+    </Label>
+
+    <br />
+    <br />
+
+    <h1>Native label</h1>
+    <label>
+      Label{' '}
+      <Checkbox.unstable_Provider>
+        <Checkbox.unstable_Trigger className={styles.root}>
+          <Checkbox.Indicator className={styles.indicator} />
+        </Checkbox.unstable_Trigger>
+      </Checkbox.unstable_Provider>
+    </label>
+
+    <h1>Native label + native checkbox</h1>
+    <label>
+      Label <input type="checkbox" />
+    </label>
+
+    <h1>Custom label + htmlFor</h1>
+    <Label htmlFor="one">Label</Label>
+    <Checkbox.unstable_Provider>
+      <Checkbox.unstable_Trigger className={styles.root} id="one">
+        <Checkbox.Indicator className={styles.indicator} />
+      </Checkbox.unstable_Trigger>
+    </Checkbox.unstable_Provider>
+
+    <br />
+    <br />
+
+    <h1>Native label + htmlFor</h1>
+    <label htmlFor="two">Label</label>
+    <Checkbox.unstable_Provider>
+      <Checkbox.unstable_Trigger className={styles.root} id="two">
+        <Checkbox.Indicator className={styles.indicator} />
+      </Checkbox.unstable_Trigger>
+    </Checkbox.unstable_Provider>
+
+    <h1>Native label + native checkbox</h1>
+    <label htmlFor="three">Label</label>
+    <input type="checkbox" id="three" />
+  </>
+);
+
+export const Controlled = () => {
+  const [checked, setChecked] = React.useState<boolean | 'indeterminate'>(true);
+
+  return (
+    <>
+      <p>This checkbox is placed adjacent to its label. The state is controlled.</p>
+      <Label htmlFor="randBox">Label</Label>{' '}
+      <Checkbox.unstable_Provider checked={checked} onCheckedChange={setChecked}>
+        <Checkbox.unstable_Trigger className={styles.root} id="randBox">
+          <Checkbox.Indicator className={styles.indicator} />
+        </Checkbox.unstable_Trigger>
+      </Checkbox.unstable_Provider>
+    </>
+  );
+};
+
+export const Indeterminate = () => {
+  const [checked, setChecked] = React.useState<boolean | 'indeterminate'>('indeterminate');
+
+  return (
+    <>
+      <p>
+        <Checkbox.unstable_Provider checked={checked} onCheckedChange={setChecked}>
+          <Checkbox.unstable_Trigger className={styles.root}>
+            <Checkbox.Indicator className={styles.indicator} />
+          </Checkbox.unstable_Trigger>
+        </Checkbox.unstable_Provider>
+      </p>
+
+      <button
+        type="button"
+        onClick={() =>
+          setChecked((prevIsChecked) =>
+            prevIsChecked === 'indeterminate' ? false : 'indeterminate'
+          )
+        }
+      >
+        Toggle indeterminate
+      </button>
+    </>
+  );
+};
+
+export const WithinForm = () => {
+  const [data, setData] = React.useState({ optional: false, required: false, stopprop: false });
+  const [checked, setChecked] = React.useState<boolean | 'indeterminate'>('indeterminate');
+
+  return (
+    <form
+      onSubmit={(event) => event.preventDefault()}
+      onChange={(event) => {
+        const input = event.target as HTMLInputElement;
+        setData((prevData) => ({ ...prevData, [input.name]: input.checked }));
+      }}
+    >
+      <fieldset>
+        <legend>optional checked: {String(data.optional)}</legend>
+        <label>
+          <Checkbox.Root
+            className={styles.root}
+            name="optional"
+            checked={checked}
+            onCheckedChange={setChecked}
+          >
+            <Checkbox.Indicator className={styles.indicator} />
+          </Checkbox.Root>{' '}
+          with label
+        </label>
+        <br />
+        <br />
+
+        <button
+          type="button"
+          onClick={() => {
+            setChecked((v) => (v === 'indeterminate' ? false : 'indeterminate'));
+          }}
+        >
+          Toggle indeterminate
+        </button>
+      </fieldset>
+
+      <br />
+      <br />
+
+      <fieldset>
+        <legend>required checked: {String(data.required)}</legend>
+        <Checkbox.unstable_Provider name="required" required>
+          <Checkbox.unstable_Trigger className={styles.root}>
+            <Checkbox.Indicator className={styles.indicator} />
+          </Checkbox.unstable_Trigger>
+          <Checkbox.unstable_BubbleInput />
+        </Checkbox.unstable_Provider>
+      </fieldset>
+
+      <br />
+      <br />
+
+      <fieldset>
+        <legend>stop propagation checked: {String(data.stopprop)}</legend>
+        <Checkbox.unstable_Provider name="stopprop">
+          <Checkbox.unstable_Trigger
+            className={styles.root}
+            onClick={(event) => event.stopPropagation()}
+          >
+            <Checkbox.Indicator className={styles.indicator} />
+          </Checkbox.unstable_Trigger>
+          <Checkbox.unstable_BubbleInput />
+        </Checkbox.unstable_Provider>
+      </fieldset>
+
+      <br />
+      <br />
+
+      <fieldset>
+        <legend>no bubble input checked: {String(data.stopprop)}</legend>
+        <Checkbox.unstable_Provider name="stopprop">
+          <Checkbox.unstable_Trigger className={styles.root}>
+            <Checkbox.Indicator className={styles.indicator} />
+          </Checkbox.unstable_Trigger>
+        </Checkbox.unstable_Provider>
+      </fieldset>
+
+      <br />
+      <br />
+
+      <button type="reset">Reset</button>
+      <button>Submit</button>
+    </form>
+  );
+};
+
+export const LegacyStyled = () => (
   <>
     <p>This checkbox is nested inside a label. The state is uncontrolled.</p>
 
@@ -53,7 +242,7 @@ export const Styled = () => (
   </>
 );
 
-export const Controlled = () => {
+export const LegacyControlled = () => {
   const [checked, setChecked] = React.useState<boolean | 'indeterminate'>(true);
 
   return (
@@ -72,7 +261,7 @@ export const Controlled = () => {
   );
 };
 
-export const Indeterminate = () => {
+export const LegacyIndeterminate = () => {
   const [checked, setChecked] = React.useState<boolean | 'indeterminate'>('indeterminate');
 
   return (
@@ -97,7 +286,7 @@ export const Indeterminate = () => {
   );
 };
 
-export const WithinForm = () => {
+export const LegacyWithinForm = () => {
   const [data, setData] = React.useState({ optional: false, required: false, stopprop: false });
   const [checked, setChecked] = React.useState<boolean | 'indeterminate'>('indeterminate');
 
@@ -170,7 +359,7 @@ export const WithinForm = () => {
   );
 };
 
-export const Animated = () => {
+export const LegacyAnimated = () => {
   const [checked, setChecked] = React.useState<boolean | 'indeterminate'>('indeterminate');
 
   return (
@@ -195,7 +384,7 @@ export const Animated = () => {
   );
 };
 
-export const Chromatic = () => (
+export const LegacyChromatic = () => (
   <>
     <h1>Uncontrolled</h1>
     <h2>Unchecked</h2>
@@ -261,6 +450,6 @@ export const Chromatic = () => (
     </Checkbox.Root>
   </>
 );
-Chromatic.parameters = { chromatic: { disable: false } };
+LegacyChromatic.parameters = { chromatic: { disable: false } };
 
 const Label = (props: any) => <LabelPrimitive.Root {...props} className={styles.label} />;
diff --git a/apps/storybook/stories/password-toggle-field.stories.module.css b/apps/storybook/stories/password-toggle-field.stories.module.css
new file mode 100644
index 00000000..9b74149c
--- /dev/null
+++ b/apps/storybook/stories/password-toggle-field.stories.module.css
@@ -0,0 +1,65 @@
+.viewport {
+  --easing: cubic-bezier(0.165, 0.84, 0.44, 1);
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  width: 100vw;
+  justify-content: center;
+  align-items: center;
+
+  margin: 0;
+  padding: 24px;
+  font-family:
+    system-ui,
+    -apple-system,
+    BlinkMacSystemFont,
+    'Segoe UI',
+    Roboto,
+    Oxygen,
+    Ubuntu,
+    Cantarell,
+    'Open Sans',
+    'Helvetica Neue',
+    sans-serif;
+  font-size: 14px;
+}
+
+.field {
+  --_field-height: 1.75rem;
+  display: flex;
+  width: 300px;
+  max-width: 100%;
+  border: 1px solid #ddd;
+  height: var(--_field-height);
+  align-items: center;
+  justify-content: space-between;
+  gap: 0.5rem;
+}
+
+.field:has(.input:focus) {
+  outline: 1px solid dodgerblue;
+  border-color: dodgerblue;
+}
+
+.input {
+  all: unset;
+  flex: 1 1 auto;
+  height: 100%;
+  vertical-align: center;
+  padding: 0 0.5rem;
+}
+
+.toggle {
+  all: unset;
+  height: var(--_field-height);
+  aspect-ratio: 1;
+  flex: 0 0 var(--_field-height);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.toggle:focus-visible {
+  outline: 2px solid dodgerblue;
+  outline-offset: -4px;
+}
diff --git a/apps/storybook/stories/password-toggle-field.stories.tsx b/apps/storybook/stories/password-toggle-field.stories.tsx
new file mode 100644
index 00000000..dfcc09d5
--- /dev/null
+++ b/apps/storybook/stories/password-toggle-field.stories.tsx
@@ -0,0 +1,150 @@
+import * as React from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { useArgs } from '@storybook/preview-api';
+import { unstable_PasswordToggleField as PasswordToggleField } from 'radix-ui';
+import styles from './password-toggle-field.stories.module.css';
+
+export default {
+  title: 'Components/PasswordToggleField',
+  component: PasswordToggleField.Root,
+} satisfies Meta<typeof PasswordToggleField.Root>;
+
+type Story = StoryObj<typeof PasswordToggleField.Root>;
+type StoryArgs = Exclude<Story['args'], undefined>;
+
+export const Uncontrolled = {
+  argTypes: {
+    children: { table: { disable: true } },
+    defaultVisible: { table: { disable: true } },
+    visible: { table: { disable: true } },
+  },
+  render: function Uncontrolled(args) {
+    return (
+      <div className={styles.viewport}>
+        <PasswordToggleField.Root {...args}>
+          <div className={styles.field}>
+            <PasswordToggleField.Input className={styles.input} />
+            <PasswordToggleField.Toggle className={styles.toggle}>
+              <PasswordToggleField.Icon
+                className={styles.toggleIcon}
+                visible={<EyeOpenIcon />}
+                hidden={<EyeClosedIcon />}
+              />
+            </PasswordToggleField.Toggle>
+          </div>
+        </PasswordToggleField.Root>
+      </div>
+    );
+  },
+} satisfies Story;
+
+export const Controlled = {
+  argTypes: {
+    children: { table: { disable: true } },
+    defaultVisible: { table: { disable: true } },
+    visible: { control: { type: 'boolean' } },
+  },
+  args: {
+    visible: false,
+  },
+  render: function Controlled(args) {
+    const [{ visible }, updateArgs] = useArgs<StoryArgs>();
+    return (
+      <div className={styles.viewport}>
+        <PasswordToggleField.Root
+          {...args}
+          visible={visible}
+          onVisiblityChange={(visible) => updateArgs({ visible })}
+        >
+          <div className={styles.field}>
+            <PasswordToggleField.Input className={styles.input} />
+            <PasswordToggleField.Toggle className={styles.toggle}>
+              <PasswordToggleField.Icon
+                className={styles.toggleIcon}
+                visible={<EyeOpenIcon />}
+                hidden={<EyeClosedIcon />}
+              />
+            </PasswordToggleField.Toggle>
+          </div>
+        </PasswordToggleField.Root>
+      </div>
+    );
+  },
+} satisfies Story;
+
+export const InsideForm = {
+  argTypes: {
+    children: { table: { disable: true } },
+    defaultVisible: { table: { disable: true } },
+    visible: { control: { type: 'boolean' } },
+  },
+  args: {
+    visible: false,
+  },
+  render: function InsideForm(args) {
+    const [{ visible }, updateArgs] = useArgs<StoryArgs>();
+    const inputRef = React.useRef<HTMLInputElement>(null);
+    return (
+      <div className={styles.viewport}>
+        <form
+          onSubmit={(event) => {
+            event.preventDefault();
+            // should be reset on submit, so this should always be hidden
+            window.alert(`Submitted! Field is ${visible ? 'visible' : 'hidden'}`);
+            inputRef.current?.focus();
+          }}
+        >
+          <PasswordToggleField.Root
+            visible={visible}
+            onVisiblityChange={(visible) => updateArgs({ visible })}
+            {...args}
+          >
+            <div className={styles.field}>
+              <PasswordToggleField.Input ref={inputRef} className={styles.input} />
+              <PasswordToggleField.Toggle className={styles.toggle}>
+                <PasswordToggleField.Icon
+                  className={styles.toggleIcon}
+                  visible={<EyeOpenIcon />}
+                  hidden={<EyeClosedIcon />}
+                />
+              </PasswordToggleField.Toggle>
+            </div>
+          </PasswordToggleField.Root>
+          <button>Submit</button>
+        </form>
+      </div>
+    );
+  },
+} satisfies Story;
+
+const EyeClosedIcon = () => (
+  <svg
+    width="15"
+    height="15"
+    viewBox="0 0 15 15"
+    fill="currentColor"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 10.9999 7.50001 10.9999C6.99965 10.9999 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.3989 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 9.99991 7.50001 9.99991C10.3078 9.99991 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
+      fillRule="evenodd"
+      clipRule="evenodd"
+    />
+  </svg>
+);
+
+const EyeOpenIcon = () => (
+  <svg
+    width="15"
+    height="15"
+    viewBox="0 0 15 15"
+    fill="currentColor"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.50001C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
+      fillRule="evenodd"
+      clipRule="evenodd"
+    />
+  </svg>
+);
diff --git a/packages/react/checkbox/src/checkbox.tsx b/packages/react/checkbox/src/checkbox.tsx
index 000fcea5..9fee8198 100644
--- a/packages/react/checkbox/src/checkbox.tsx
+++ b/packages/react/checkbox/src/checkbox.tsx
@@ -10,10 +10,6 @@ import { Primitive } from '@radix-ui/react-primitive';
 
 import type { Scope } from '@radix-ui/react-context';
 
-/* -------------------------------------------------------------------------------------------------
- * Checkbox
- * -----------------------------------------------------------------------------------------------*/
-
 const CHECKBOX_NAME = 'Checkbox';
 
 type ScopedProps<P> = P & { __scopeCheckbox?: Scope };
@@ -21,14 +17,179 @@ const [createCheckboxContext, createCheckboxScope] = createContextScope(CHECKBOX
 
 type CheckedState = boolean | 'indeterminate';
 
-type CheckboxContextValue = {
-  state: CheckedState;
-  disabled?: boolean;
+type CheckboxContextValue<State extends CheckedState | boolean = CheckedState> = {
+  checked: State | boolean;
+  setChecked: React.Dispatch<React.SetStateAction<State | boolean>>;
+  disabled: boolean | undefined;
+  control: HTMLButtonElement | null;
+  setControl: React.Dispatch<React.SetStateAction<HTMLButtonElement | null>>;
+  name: string | undefined;
+  form: string | undefined;
+  value: string | number | readonly string[];
+  hasConsumerStoppedPropagationRef: React.RefObject<boolean>;
+  required: boolean | undefined;
+  defaultChecked: boolean | undefined;
+  isFormControl: boolean;
+  bubbleInput: HTMLInputElement | null;
+  setBubbleInput: React.Dispatch<React.SetStateAction<HTMLInputElement | null>>;
 };
 
-const [CheckboxProvider, useCheckboxContext] =
+const [CheckboxProviderImpl, useCheckboxContext] =
   createCheckboxContext<CheckboxContextValue>(CHECKBOX_NAME);
 
+/* -------------------------------------------------------------------------------------------------
+ * CheckboxProvider
+ * -----------------------------------------------------------------------------------------------*/
+
+interface CheckboxProviderProps<State extends CheckedState = CheckedState> {
+  checked?: State | boolean;
+  defaultChecked?: State | boolean;
+  required?: boolean;
+  onCheckedChange?(checked: State | boolean): void;
+  name?: string;
+  form?: string;
+  disabled?: boolean;
+  value?: string | number | readonly string[];
+  children?: React.ReactNode;
+}
+
+function CheckboxProvider<State extends CheckedState = CheckedState>({
+  __scopeCheckbox,
+  checked: checkedProp,
+  children,
+  defaultChecked,
+  disabled,
+  form,
+  name,
+  onCheckedChange,
+  required,
+  value = 'on',
+  // @ts-expect-error
+  internal_do_not_use_render,
+}: ScopedProps<CheckboxProviderProps<State>>) {
+  const [checked, setChecked] = useControllableState({
+    prop: checkedProp,
+    defaultProp: defaultChecked ?? false,
+    onChange: onCheckedChange,
+    caller: CHECKBOX_NAME,
+  });
+  const [control, setControl] = React.useState<HTMLButtonElement | null>(null);
+  const [bubbleInput, setBubbleInput] = React.useState<HTMLInputElement | null>(null);
+  const hasConsumerStoppedPropagationRef = React.useRef(false);
+  const isFormControl = control
+    ? !!form || !!control.closest('form')
+    : // We set this to true by default so that events bubble to forms without JS (SSR)
+      true;
+
+  const context: CheckboxContextValue<State> = {
+    checked: checked,
+    disabled: disabled,
+    setChecked: setChecked,
+    control: control,
+    setControl: setControl,
+    name: name,
+    form: form,
+    value: value,
+    hasConsumerStoppedPropagationRef: hasConsumerStoppedPropagationRef,
+    required: required,
+    defaultChecked: isIndeterminate(defaultChecked) ? false : defaultChecked,
+    isFormControl: isFormControl,
+    bubbleInput,
+    setBubbleInput,
+  };
+
+  return (
+    <CheckboxProviderImpl
+      scope={__scopeCheckbox}
+      {...(context as unknown as CheckboxContextValue<CheckedState>)}
+    >
+      {isFunction(internal_do_not_use_render) ? internal_do_not_use_render(context) : children}
+    </CheckboxProviderImpl>
+  );
+}
+
+/* -------------------------------------------------------------------------------------------------
+ * CheckboxTrigger
+ * -----------------------------------------------------------------------------------------------*/
+
+const TRIGGER_NAME = 'CheckboxTrigger';
+
+interface CheckboxTriggerProps
+  extends Omit<
+    React.ComponentPropsWithoutRef<typeof Primitive.button>,
+    keyof CheckboxProviderProps
+  > {
+  children?: React.ReactNode;
+}
+
+const CheckboxTrigger = React.forwardRef<HTMLButtonElement, CheckboxTriggerProps>(
+  (
+    { __scopeCheckbox, onKeyDown, onClick, ...checkboxProps }: ScopedProps<CheckboxTriggerProps>,
+    forwardedRef
+  ) => {
+    const {
+      control,
+      value,
+      disabled,
+      checked,
+      required,
+      setControl,
+      setChecked,
+      hasConsumerStoppedPropagationRef,
+      isFormControl,
+      bubbleInput,
+    } = useCheckboxContext(TRIGGER_NAME, __scopeCheckbox);
+    const composedRefs = useComposedRefs(forwardedRef, setControl);
+
+    const initialCheckedStateRef = React.useRef(checked);
+    React.useEffect(() => {
+      const form = control?.form;
+      if (form) {
+        const reset = () => setChecked(initialCheckedStateRef.current);
+        form.addEventListener('reset', reset);
+        return () => form.removeEventListener('reset', reset);
+      }
+    }, [control, setChecked]);
+
+    return (
+      <Primitive.button
+        type="button"
+        role="checkbox"
+        aria-checked={isIndeterminate(checked) ? 'mixed' : checked}
+        aria-required={required}
+        data-state={getState(checked)}
+        data-disabled={disabled ? '' : undefined}
+        disabled={disabled}
+        value={value}
+        {...checkboxProps}
+        ref={composedRefs}
+        onKeyDown={composeEventHandlers(onKeyDown, (event) => {
+          // According to WAI ARIA, Checkboxes don't activate on enter keypress
+          if (event.key === 'Enter') event.preventDefault();
+        })}
+        onClick={composeEventHandlers(onClick, (event) => {
+          setChecked((prevChecked) => (isIndeterminate(prevChecked) ? true : !prevChecked));
+          if (bubbleInput && isFormControl) {
+            hasConsumerStoppedPropagationRef.current = event.isPropagationStopped();
+            // if checkbox has a bubble input and is a form control, stop
+            // propagation from the button so that we only propagate one click
+            // event (from the input). We propagate changes from an input so
+            // that native form validation works and form events reflect
+            // checkbox updates.
+            if (!hasConsumerStoppedPropagationRef.current) event.stopPropagation();
+          }
+        })}
+      />
+    );
+  }
+);
+
+CheckboxTrigger.displayName = TRIGGER_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * Checkbox
+ * -----------------------------------------------------------------------------------------------*/
+
 type CheckboxElement = React.ElementRef<typeof Primitive.button>;
 type PrimitiveButtonProps = React.ComponentPropsWithoutRef<typeof Primitive.button>;
 interface CheckboxProps extends Omit<PrimitiveButtonProps, 'checked' | 'defaultChecked'> {
@@ -43,82 +204,35 @@ const Checkbox = React.forwardRef<CheckboxElement, CheckboxProps>(
     const {
       __scopeCheckbox,
       name,
-      checked: checkedProp,
+      checked,
       defaultChecked,
       required,
       disabled,
-      value = 'on',
+      value,
       onCheckedChange,
       form,
       ...checkboxProps
     } = props;
-    const [button, setButton] = React.useState<HTMLButtonElement | null>(null);
-    const composedRefs = useComposedRefs(forwardedRef, (node) => setButton(node));
-    const hasConsumerStoppedPropagationRef = React.useRef(false);
-    // We set this to true by default so that events bubble to forms without JS (SSR)
-    const isFormControl = button ? form || !!button.closest('form') : true;
-    const [checked, setChecked] = useControllableState({
-      prop: checkedProp,
-      defaultProp: defaultChecked ?? false,
-      onChange: onCheckedChange,
-      caller: CHECKBOX_NAME,
-    });
-    const initialCheckedStateRef = React.useRef(checked);
-    React.useEffect(() => {
-      const form = button?.form;
-      if (form) {
-        const reset = () => setChecked(initialCheckedStateRef.current);
-        form.addEventListener('reset', reset);
-        return () => form.removeEventListener('reset', reset);
-      }
-    }, [button, setChecked]);
 
     return (
-      <CheckboxProvider scope={__scopeCheckbox} state={checked} disabled={disabled}>
-        <Primitive.button
-          type="button"
-          role="checkbox"
-          aria-checked={isIndeterminate(checked) ? 'mixed' : checked}
-          aria-required={required}
-          data-state={getState(checked)}
-          data-disabled={disabled ? '' : undefined}
-          disabled={disabled}
-          value={value}
-          {...checkboxProps}
-          ref={composedRefs}
-          onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
-            // According to WAI ARIA, Checkboxes don't activate on enter keypress
-            if (event.key === 'Enter') event.preventDefault();
-          })}
-          onClick={composeEventHandlers(props.onClick, (event) => {
-            setChecked((prevChecked) => (isIndeterminate(prevChecked) ? true : !prevChecked));
-            if (isFormControl) {
-              hasConsumerStoppedPropagationRef.current = event.isPropagationStopped();
-              // if checkbox is in a form, stop propagation from the button so that we only propagate
-              // one click event (from the input). We propagate changes from an input so that native
-              // form validation works and form events reflect checkbox updates.
-              if (!hasConsumerStoppedPropagationRef.current) event.stopPropagation();
-            }
-          })}
-        />
-        {isFormControl && (
-          <CheckboxBubbleInput
-            control={button}
-            bubbles={!hasConsumerStoppedPropagationRef.current}
-            name={name}
-            value={value}
-            checked={checked}
-            required={required}
-            disabled={disabled}
-            form={form}
-            // We transform because the input is absolutely positioned but we have
-            // rendered it **after** the button. This pulls it back to sit on top
-            // of the button.
-            style={{ transform: 'translateX(-100%)' }}
-            defaultChecked={isIndeterminate(defaultChecked) ? false : defaultChecked}
-          />
+      <CheckboxProvider
+        __scopeCheckbox={__scopeCheckbox}
+        checked={checked}
+        defaultChecked={defaultChecked}
+        disabled={disabled}
+        required={required}
+        onCheckedChange={onCheckedChange}
+        name={name}
+        form={form}
+        value={value}
+        // @ts-expect-error
+        internal_do_not_use_render={({ isFormControl }: CheckboxContextValue) => (
+          <>
+            <CheckboxTrigger {...checkboxProps} ref={forwardedRef} />
+            {isFormControl && <CheckboxBubbleInput />}
+          </>
         )}
-      </CheckboxProvider>
+      />
     );
   }
 );
@@ -146,9 +260,11 @@ const CheckboxIndicator = React.forwardRef<CheckboxIndicatorElement, CheckboxInd
     const { __scopeCheckbox, forceMount, ...indicatorProps } = props;
     const context = useCheckboxContext(INDICATOR_NAME, __scopeCheckbox);
     return (
-      <Presence present={forceMount || isIndeterminate(context.state) || context.state === true}>
+      <Presence
+        present={forceMount || isIndeterminate(context.checked) || context.checked === true}
+      >
         <Primitive.span
-          data-state={getState(context.state)}
+          data-state={getState(context.checked)}
           data-disabled={context.disabled ? '' : undefined}
           {...indicatorProps}
           ref={forwardedRef}
@@ -168,32 +284,38 @@ CheckboxIndicator.displayName = INDICATOR_NAME;
 const BUBBLE_INPUT_NAME = 'CheckboxBubbleInput';
 
 type InputProps = React.ComponentPropsWithoutRef<typeof Primitive.input>;
-interface CheckboxBubbleInputProps extends Omit<InputProps, 'checked'> {
-  checked: CheckedState;
-  control: HTMLElement | null;
-  bubbles: boolean;
-}
+interface CheckboxBubbleInputProps extends Omit<InputProps, 'checked'> {}
 
 const CheckboxBubbleInput = React.forwardRef<HTMLInputElement, CheckboxBubbleInputProps>(
   (
     {
       __scopeCheckbox,
-      control,
-      checked,
-      bubbles = true,
-      defaultChecked,
+
       ...props
     }: ScopedProps<CheckboxBubbleInputProps>,
     forwardedRef
   ) => {
-    const ref = React.useRef<HTMLInputElement>(null);
-    const composedRefs = useComposedRefs(ref, forwardedRef);
+    const {
+      control,
+      hasConsumerStoppedPropagationRef,
+      checked,
+      defaultChecked,
+      required,
+      disabled,
+      name,
+      value,
+      form,
+      bubbleInput,
+      setBubbleInput,
+    } = useCheckboxContext(BUBBLE_INPUT_NAME, __scopeCheckbox);
+
+    const composedRefs = useComposedRefs(forwardedRef, setBubbleInput);
     const prevChecked = usePrevious(checked);
     const controlSize = useSize(control);
 
     // Bubble checked change to parents (e.g form change event)
     React.useEffect(() => {
-      const input = ref.current;
+      const input = bubbleInput;
       if (!input) return;
 
       const inputProto = window.HTMLInputElement.prototype;
@@ -203,13 +325,14 @@ const CheckboxBubbleInput = React.forwardRef<HTMLInputElement, CheckboxBubbleInp
       ) as PropertyDescriptor;
       const setChecked = descriptor.set;
 
+      const bubbles = !hasConsumerStoppedPropagationRef.current;
       if (prevChecked !== checked && setChecked) {
         const event = new Event('click', { bubbles });
         input.indeterminate = isIndeterminate(checked);
         setChecked.call(input, isIndeterminate(checked) ? false : checked);
         input.dispatchEvent(event);
       }
-    }, [prevChecked, checked, bubbles]);
+    }, [bubbleInput, prevChecked, checked, hasConsumerStoppedPropagationRef]);
 
     const defaultCheckedRef = React.useRef(isIndeterminate(checked) ? false : checked);
     return (
@@ -217,6 +340,11 @@ const CheckboxBubbleInput = React.forwardRef<HTMLInputElement, CheckboxBubbleInp
         type="checkbox"
         aria-hidden
         defaultChecked={defaultChecked ?? defaultCheckedRef.current}
+        required={required}
+        disabled={disabled}
+        name={name}
+        value={value}
+        form={form}
         {...props}
         tabIndex={-1}
         ref={composedRefs}
@@ -227,6 +355,10 @@ const CheckboxBubbleInput = React.forwardRef<HTMLInputElement, CheckboxBubbleInp
           pointerEvents: 'none',
           opacity: 0,
           margin: 0,
+          // We transform because the input is absolutely positioned but we have
+          // rendered it **after** the button. This pulls it back to sit on top
+          // of the button.
+          transform: 'translateX(-100%)',
         }}
       />
     );
@@ -237,6 +369,10 @@ CheckboxBubbleInput.displayName = BUBBLE_INPUT_NAME;
 
 /* ---------------------------------------------------------------------------------------------- */
 
+function isFunction(value: unknown): value is (...args: any[]) => any {
+  return typeof value === 'function';
+}
+
 function isIndeterminate(checked?: CheckedState): checked is 'indeterminate' {
   return checked === 'indeterminate';
 }
@@ -245,16 +381,32 @@ function getState(checked: CheckedState) {
   return isIndeterminate(checked) ? 'indeterminate' : checked ? 'checked' : 'unchecked';
 }
 
+const Provider = CheckboxProvider;
+const Trigger = CheckboxTrigger;
 const Root = Checkbox;
 const Indicator = CheckboxIndicator;
+const BubbleInput = CheckboxBubbleInput;
 
 export {
   createCheckboxScope,
   //
   Checkbox,
+  CheckboxProvider,
+  CheckboxTrigger,
   CheckboxIndicator,
+  CheckboxBubbleInput,
   //
   Root,
+  Trigger,
+  Provider,
   Indicator,
+  BubbleInput,
+};
+export type {
+  CheckboxProps,
+  CheckboxProviderProps,
+  CheckboxTriggerProps,
+  CheckboxIndicatorProps,
+  CheckboxBubbleInputProps,
+  CheckedState,
 };
-export type { CheckboxProps, CheckboxIndicatorProps, CheckedState };
diff --git a/packages/react/checkbox/src/index.ts b/packages/react/checkbox/src/index.ts
index 8b824ca3..3727c7ff 100644
--- a/packages/react/checkbox/src/index.ts
+++ b/packages/react/checkbox/src/index.ts
@@ -3,9 +3,22 @@ export {
   createCheckboxScope,
   //
   Checkbox,
+  CheckboxProvider as unstable_CheckboxProvider,
+  CheckboxTrigger as unstable_CheckboxTrigger,
   CheckboxIndicator,
+  CheckboxBubbleInput as unstable_CheckboxBubbleInput,
   //
   Root,
+  Provider as unstable_Provider,
+  Trigger as unstable_Trigger,
   Indicator,
+  BubbleInput as unstable_BubbleInput,
+} from './checkbox';
+export type {
+  CheckboxProps,
+  CheckboxProviderProps as unstable_CheckboxProviderProps,
+  CheckboxTriggerProps as unstable_CheckboxTriggerProps,
+  CheckboxIndicatorProps,
+  CheckboxBubbleInputProps as unstable_CheckboxBubbleInputProps,
+  CheckedState,
 } from './checkbox';
-export type { CheckboxProps, CheckboxIndicatorProps, CheckedState } from './checkbox';
diff --git a/packages/react/password-toggle-field/README.md b/packages/react/password-toggle-field/README.md
new file mode 100644
index 00000000..e5ccc9cc
--- /dev/null
+++ b/packages/react/password-toggle-field/README.md
@@ -0,0 +1,13 @@
+# `react-password-toggle-field`
+
+## Installation
+
+```sh
+$ yarn add radix-ui
+# or
+$ npm install radix-ui
+```
+
+## Usage
+
+View docs [here](https://radix-ui.com/primitives/docs/components/password-toggle-field).
diff --git a/packages/react/password-toggle-field/eslint.config.mjs b/packages/react/password-toggle-field/eslint.config.mjs
new file mode 100644
index 00000000..26d68256
--- /dev/null
+++ b/packages/react/password-toggle-field/eslint.config.mjs
@@ -0,0 +1,4 @@
+// @ts-check
+import { configs } from '@repo/eslint-config/react-package';
+
+export default configs;
diff --git a/packages/react/password-toggle-field/package.json b/packages/react/password-toggle-field/package.json
new file mode 100644
index 00000000..11929df6
--- /dev/null
+++ b/packages/react/password-toggle-field/package.json
@@ -0,0 +1,80 @@
+{
+  "name": "@radix-ui/react-password-toggle-field",
+  "version": "0.0.0",
+  "license": "MIT",
+  "source": "./src/index.ts",
+  "main": "./src/index.ts",
+  "module": "./src/index.ts",
+  "publishConfig": {
+    "main": "./dist/index.js",
+    "module": "./dist/index.mjs",
+    "types": "./dist/index.d.ts",
+    "exports": {
+      ".": {
+        "import": {
+          "types": "./dist/index.d.mts",
+          "default": "./dist/index.mjs"
+        },
+        "require": {
+          "types": "./dist/index.d.ts",
+          "default": "./dist/index.js"
+        }
+      }
+    }
+  },
+  "files": [
+    "src",
+    "dist",
+    "README.md"
+  ],
+  "sideEffects": false,
+  "scripts": {
+    "lint": "eslint --max-warnings 0 src",
+    "clean": "rm -rf dist",
+    "typecheck": "tsc --noEmit",
+    "build": "radix-build"
+  },
+  "dependencies": {
+    "@radix-ui/primitive": "workspace:*",
+    "@radix-ui/react-compose-refs": "workspace:*",
+    "@radix-ui/react-context": "workspace:*",
+    "@radix-ui/react-id": "workspace:*",
+    "@radix-ui/react-primitive": "workspace:*",
+    "@radix-ui/react-use-controllable-state": "workspace:*",
+    "@radix-ui/react-use-effect-event": "workspace:*",
+    "@radix-ui/react-use-is-hydrated": "workspace:*"
+  },
+  "devDependencies": {
+    "@repo/builder": "workspace:*",
+    "@repo/eslint-config": "workspace:*",
+    "@repo/typescript-config": "workspace:*",
+    "@types/react": "^19.0.7",
+    "@types/react-dom": "^19.0.3",
+    "eslint": "^9.18.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "typescript": "^5.7.3"
+  },
+  "peerDependencies": {
+    "@types/react": "*",
+    "@types/react-dom": "*",
+    "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+    "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+  },
+  "peerDependenciesMeta": {
+    "@types/react": {
+      "optional": true
+    },
+    "@types/react-dom": {
+      "optional": true
+    }
+  },
+  "homepage": "https://radix-ui.com/primitives",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/radix-ui/primitives.git"
+  },
+  "bugs": {
+    "url": "https://github.com/radix-ui/primitives/issues"
+  }
+}
diff --git a/packages/react/password-toggle-field/src/index.ts b/packages/react/password-toggle-field/src/index.ts
new file mode 100644
index 00000000..f88e4524
--- /dev/null
+++ b/packages/react/password-toggle-field/src/index.ts
@@ -0,0 +1,21 @@
+'use client';
+export type {
+  PasswordToggleFieldProps,
+  PasswordToggleFieldInputProps,
+  PasswordToggleFieldToggleProps,
+  PasswordToggleFieldIconProps,
+  PasswordToggleFieldSlotProps,
+} from './password-toggle-field';
+export {
+  PasswordToggleField,
+  PasswordToggleFieldInput,
+  PasswordToggleFieldToggle,
+  PasswordToggleFieldIcon,
+  PasswordToggleFieldSlot,
+  //
+  Root,
+  Input,
+  Toggle,
+  Icon,
+  Slot,
+} from './password-toggle-field';
diff --git a/packages/react/password-toggle-field/src/password-toggle-field.tsx b/packages/react/password-toggle-field/src/password-toggle-field.tsx
new file mode 100644
index 00000000..a132469a
--- /dev/null
+++ b/packages/react/password-toggle-field/src/password-toggle-field.tsx
@@ -0,0 +1,479 @@
+import * as React from 'react';
+import { flushSync } from 'react-dom';
+import { composeEventHandlers } from '@radix-ui/primitive';
+import { useControllableState } from '@radix-ui/react-use-controllable-state';
+import { Primitive } from '@radix-ui/react-primitive';
+import { useComposedRefs } from '@radix-ui/react-compose-refs';
+import { useId } from '@radix-ui/react-id';
+import { useIsHydrated } from '@radix-ui/react-use-is-hydrated';
+import { useEffectEvent } from '@radix-ui/react-use-effect-event';
+import type { Scope } from '@radix-ui/react-context';
+import { createContextScope } from '@radix-ui/react-context';
+
+const PASSWORD_TOGGLE_FIELD_NAME = 'PasswordToggleField';
+
+/* -------------------------------------------------------------------------------------------------
+ * PasswordToggleFieldProvider
+ * -----------------------------------------------------------------------------------------------*/
+
+type InternalFocusState = {
+  clickTriggered: boolean;
+  selectionStart: number | null;
+  selectionEnd: number | null;
+};
+
+interface PasswordToggleFieldContextValue {
+  inputId: string;
+  inputRef: React.RefObject<HTMLInputElement | null>;
+  visible: boolean;
+  setVisible: React.Dispatch<React.SetStateAction<boolean>>;
+  syncInputId: (providedId: string | number | undefined) => void;
+  focusState: React.RefObject<InternalFocusState>;
+}
+
+const [createPasswordToggleFieldContext] = createContextScope(PASSWORD_TOGGLE_FIELD_NAME);
+const [PasswordToggleFieldProvider, usePasswordToggleFieldContext] =
+  createPasswordToggleFieldContext<PasswordToggleFieldContextValue>(PASSWORD_TOGGLE_FIELD_NAME);
+
+/* -------------------------------------------------------------------------------------------------
+ * PasswordToggleField
+ * -----------------------------------------------------------------------------------------------*/
+
+type ScopedProps<P> = P & { __scopePasswordToggleField?: Scope };
+
+interface PasswordToggleFieldProps {
+  id?: string;
+  visible?: boolean;
+  defaultVisible?: boolean;
+  onVisiblityChange?: (visible: boolean) => void;
+  children?: React.ReactNode;
+}
+
+const INITIAL_FOCUS_STATE: InternalFocusState = {
+  clickTriggered: false,
+  selectionStart: null,
+  selectionEnd: null,
+};
+
+const PasswordToggleField: React.FC<PasswordToggleFieldProps> = ({
+  __scopePasswordToggleField,
+  ...props
+}: ScopedProps<PasswordToggleFieldProps>) => {
+  const baseId = useId(props.id);
+  const defaultInputId = `${baseId}-input`;
+  const [inputIdState, setInputIdState] = React.useState<null | string>(defaultInputId);
+  const inputId = inputIdState ?? defaultInputId;
+  const syncInputId = React.useCallback(
+    (providedId: string | number | undefined) =>
+      setInputIdState(providedId != null ? String(providedId) : null),
+    []
+  );
+
+  const { visible: visibleProp, defaultVisible, onVisiblityChange, children } = props;
+  const [visible = false, setVisible] = useControllableState({
+    caller: PASSWORD_TOGGLE_FIELD_NAME,
+    prop: visibleProp,
+    defaultProp: defaultVisible ?? false,
+    onChange: onVisiblityChange,
+  });
+
+  const inputRef = React.useRef<HTMLInputElement | null>(null);
+  const focusState = React.useRef<InternalFocusState>(INITIAL_FOCUS_STATE);
+
+  return (
+    <PasswordToggleFieldProvider
+      scope={__scopePasswordToggleField}
+      inputId={inputId}
+      inputRef={inputRef}
+      setVisible={setVisible}
+      syncInputId={syncInputId}
+      visible={visible}
+      focusState={focusState}
+    >
+      {children}
+    </PasswordToggleFieldProvider>
+  );
+};
+PasswordToggleField.displayName = PASSWORD_TOGGLE_FIELD_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * PasswordToggleFieldInput
+ * -----------------------------------------------------------------------------------------------*/
+
+const PASSWORD_TOGGLE_FIELD_INPUT_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Input';
+
+type PrimitiveInputProps = React.ComponentPropsWithoutRef<'input'>;
+
+interface PasswordToggleFieldOwnProps {
+  autoComplete?: 'current-password' | 'new-password';
+}
+
+interface PasswordToggleFieldInputProps
+  extends PasswordToggleFieldOwnProps,
+    Omit<PrimitiveInputProps, keyof PasswordToggleFieldOwnProps | 'type'> {
+  autoComplete?: 'current-password' | 'new-password';
+}
+
+const PasswordToggleFieldInput = React.forwardRef<HTMLInputElement, PasswordToggleFieldInputProps>(
+  (
+    {
+      __scopePasswordToggleField,
+      autoComplete = 'current-password',
+      autoCapitalize = 'off',
+      spellCheck = false,
+      id: idProp,
+      ...props
+    }: ScopedProps<PasswordToggleFieldInputProps>,
+    forwardedRef
+  ) => {
+    const { visible, inputRef, inputId, syncInputId, setVisible, focusState } =
+      usePasswordToggleFieldContext(PASSWORD_TOGGLE_FIELD_INPUT_NAME, __scopePasswordToggleField);
+
+    React.useEffect(() => {
+      syncInputId(idProp);
+    }, [idProp, syncInputId]);
+
+    // We want to reset the visibility to `false` to revert the input to
+    // `type="password"` when:
+    // - The form is reset (for consistency with other form controls)
+    // - The form is submitted (to prevent the browser from remembering the
+    //   input's value.
+    //
+    // See "Keeping things secure":
+    //   https://technology.blog.gov.uk/2021/04/19/simple-things-are-complicated-making-a-show-password-option/)
+    const _setVisible = useEffectEvent(setVisible);
+    React.useEffect(() => {
+      const inputElement = inputRef.current;
+      const form = inputElement?.form;
+      if (!form) {
+        return;
+      }
+
+      const controller = new AbortController();
+      form.addEventListener(
+        'reset',
+        (event) => {
+          if (!event.defaultPrevented) {
+            _setVisible(false);
+          }
+        },
+        { signal: controller.signal }
+      );
+      form.addEventListener(
+        'submit',
+        () => {
+          // always reset the visibility on submit regardless of whether the
+          // default action is prevented
+          _setVisible(false);
+        },
+        { signal: controller.signal }
+      );
+      return () => {
+        controller.abort();
+      };
+    }, [inputRef, _setVisible]);
+
+    return (
+      <Primitive.input
+        {...props}
+        id={idProp ?? inputId}
+        autoCapitalize={autoCapitalize}
+        autoComplete={autoComplete}
+        ref={useComposedRefs(forwardedRef, inputRef)}
+        spellCheck={spellCheck}
+        type={visible ? 'text' : 'password'}
+        onBlur={composeEventHandlers(props.onBlur, (event) => {
+          // get the cursor position
+          const { selectionStart, selectionEnd } = event.currentTarget;
+          focusState.current.selectionStart = selectionStart;
+          focusState.current.selectionEnd = selectionEnd;
+        })}
+      />
+    );
+  }
+);
+PasswordToggleFieldInput.displayName = PASSWORD_TOGGLE_FIELD_INPUT_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * PasswordToggleFieldToggle
+ * -----------------------------------------------------------------------------------------------*/
+
+const PASSWORD_TOGGLE_FIELD_TOGGLE_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Toggle';
+
+type PrimitiveButtonProps = React.ComponentPropsWithoutRef<'button'>;
+
+interface PasswordToggleFieldToggleProps extends Omit<PrimitiveButtonProps, 'type'> {}
+
+const PasswordToggleFieldToggle = React.forwardRef<
+  HTMLButtonElement,
+  PasswordToggleFieldToggleProps
+>(
+  (
+    {
+      __scopePasswordToggleField,
+      onClick,
+      onPointerDown,
+      onPointerCancel,
+      onPointerUp,
+      onFocus,
+      children,
+      'aria-label': ariaLabelProp,
+      'aria-controls': ariaControls,
+      'aria-hidden': ariaHidden,
+      tabIndex,
+      ...props
+    }: ScopedProps<PasswordToggleFieldToggleProps>,
+    forwardedRef
+  ) => {
+    const { setVisible, visible, inputRef, inputId, focusState } = usePasswordToggleFieldContext(
+      PASSWORD_TOGGLE_FIELD_TOGGLE_NAME,
+      __scopePasswordToggleField
+    );
+    const [internalAriaLabel, setInternalAriaLabel] = React.useState<string | undefined>(undefined);
+    const elementRef = React.useRef<HTMLButtonElement>(null);
+    const ref = useComposedRefs(forwardedRef, elementRef);
+    const isHydrated = useIsHydrated();
+
+    React.useEffect(() => {
+      const element = elementRef.current;
+      if (!element || ariaLabelProp) {
+        setInternalAriaLabel(undefined);
+        return;
+      }
+
+      const DEFAULT_ARIA_LABEL = visible ? 'Hide password' : 'Show password';
+
+      function checkForInnerTextLabel(textContent: string | undefined | null) {
+        const text = textContent ? textContent : undefined;
+        // If the element has inner text, no need to force an aria-label.
+        setInternalAriaLabel(text ? undefined : DEFAULT_ARIA_LABEL);
+      }
+
+      checkForInnerTextLabel(element.textContent);
+
+      const observer = new MutationObserver((entries) => {
+        let textContent: string | undefined;
+        for (const entry of entries) {
+          if (entry.type === 'characterData') {
+            if (element.textContent) {
+              textContent = element.textContent;
+            }
+          }
+        }
+        checkForInnerTextLabel(textContent);
+      });
+      observer.observe(element, { characterData: true, subtree: true });
+      return () => {
+        observer.disconnect();
+      };
+    }, [visible, ariaLabelProp]);
+
+    const ariaLabel = ariaLabelProp || internalAriaLabel;
+
+    // Before hydration the button will not work, but we want to render it
+    // regardless to prevent potential layout shift. Hide it from assistive tech
+    // by default. Post-hydration it will be visible, focusable and associated
+    // with the input via aria-controls.
+    if (!isHydrated) {
+      ariaHidden ??= true;
+      tabIndex ??= -1;
+    } else {
+      ariaControls ??= inputId;
+    }
+
+    React.useEffect(() => {
+      let cleanup = () => {};
+      const ownerWindow = elementRef.current?.ownerDocument?.defaultView || window;
+      const reset = () => (focusState.current.clickTriggered = false);
+      const handlePointerUp = () => (cleanup = requestIdleCallback(ownerWindow, reset));
+      ownerWindow.addEventListener('pointerup', handlePointerUp);
+      return () => {
+        cleanup();
+        ownerWindow.removeEventListener('pointerup', handlePointerUp);
+      };
+    }, [focusState]);
+
+    return (
+      <Primitive.button
+        aria-controls={ariaControls}
+        aria-hidden={ariaHidden}
+        aria-label={ariaLabel}
+        ref={ref}
+        id={inputId}
+        {...props}
+        onPointerDown={composeEventHandlers(onPointerDown, () => {
+          focusState.current.clickTriggered = true;
+        })}
+        onPointerCancel={(event) => {
+          // do not use `composeEventHandlers` here because we always want to
+          // reset the ref on cancellation, regardless of whether the user has
+          // called preventDefault on the event
+          onPointerCancel?.(event);
+          focusState.current = INITIAL_FOCUS_STATE;
+        }}
+        // do not use `composeEventHandlers` here because we always want to
+        // reset the ref after click, regardless of whether the user has
+        // called preventDefault on the event
+        onClick={(event) => {
+          onClick?.(event);
+          if (event.defaultPrevented) {
+            focusState.current = INITIAL_FOCUS_STATE;
+            return;
+          }
+
+          flushSync(() => {
+            setVisible((s) => !s);
+          });
+          if (focusState.current.clickTriggered) {
+            const input = inputRef.current;
+            if (input) {
+              const { selectionStart, selectionEnd } = focusState.current;
+              input.focus();
+              if (selectionStart !== null || selectionEnd !== null) {
+                // wait a tick so that focus has settled, then restore select position
+                requestAnimationFrame(() => {
+                  // make sure the input still has focus (developer may have
+                  // programatically moved focus elsewhere)
+                  if (input.ownerDocument.activeElement === input) {
+                    input.selectionStart = selectionStart;
+                    input.selectionEnd = selectionEnd;
+                  }
+                });
+              }
+            }
+          }
+          focusState.current = INITIAL_FOCUS_STATE;
+        }}
+        onPointerUp={(event) => {
+          onPointerUp?.(event);
+          // if click handler hasn't been called at this point, it may have been
+          // intercepted, in which case we still want to reset our internal
+          // state
+          setTimeout(() => {
+            focusState.current = INITIAL_FOCUS_STATE;
+          }, 50);
+        }}
+        type="button"
+      >
+        {children}
+      </Primitive.button>
+    );
+  }
+);
+PasswordToggleFieldToggle.displayName = PASSWORD_TOGGLE_FIELD_TOGGLE_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * PasswordToggleFieldSlot
+ * -----------------------------------------------------------------------------------------------*/
+
+const PASSWORD_TOGGLE_FIELD_SLOT_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Slot';
+
+interface PasswordToggleFieldSlotDeclarativeProps {
+  visible: React.ReactNode;
+  hidden: React.ReactNode;
+}
+
+interface PasswordToggleFieldSlotRenderProps {
+  render: (args: { visible: boolean }) => React.ReactElement;
+}
+
+type PasswordToggleFieldSlotProps =
+  | PasswordToggleFieldSlotDeclarativeProps
+  | PasswordToggleFieldSlotRenderProps;
+
+const PasswordToggleFieldSlot: React.FC<PasswordToggleFieldSlotProps> = ({
+  __scopePasswordToggleField,
+  ...props
+}: ScopedProps<PasswordToggleFieldSlotProps>) => {
+  const { visible } = usePasswordToggleFieldContext(
+    PASSWORD_TOGGLE_FIELD_SLOT_NAME,
+    __scopePasswordToggleField
+  );
+
+  return 'render' in props
+    ? //
+      props.render({ visible })
+    : visible
+      ? props.visible
+      : props.hidden;
+};
+PasswordToggleFieldSlot.displayName = PASSWORD_TOGGLE_FIELD_SLOT_NAME;
+
+/* -------------------------------------------------------------------------------------------------
+ * PasswordToggleFieldIcon
+ * -----------------------------------------------------------------------------------------------*/
+
+const PASSWORD_TOGGLE_FIELD_ICON_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Icon';
+
+type PrimitiveSvgProps = React.ComponentPropsWithoutRef<'svg'>;
+
+interface PasswordToggleFieldIconProps extends Omit<PrimitiveSvgProps, 'children'> {
+  visible: React.ReactElement;
+  hidden: React.ReactElement;
+}
+
+const PasswordToggleFieldIcon = React.forwardRef<SVGSVGElement, PasswordToggleFieldIconProps>(
+  (
+    {
+      __scopePasswordToggleField,
+      // @ts-expect-error
+      children,
+      ...props
+    }: ScopedProps<PasswordToggleFieldIconProps>,
+    forwardedRef
+  ) => {
+    const { visible } = usePasswordToggleFieldContext(
+      PASSWORD_TOGGLE_FIELD_ICON_NAME,
+      __scopePasswordToggleField
+    );
+    const { visible: visibleIcon, hidden: hiddenIcon, ...domProps } = props;
+    return (
+      <Primitive.svg {...domProps} ref={forwardedRef} aria-hidden asChild>
+        {visible ? visibleIcon : hiddenIcon}
+      </Primitive.svg>
+    );
+  }
+);
+PasswordToggleFieldIcon.displayName = PASSWORD_TOGGLE_FIELD_ICON_NAME;
+
+export {
+  PasswordToggleField,
+  PasswordToggleFieldInput,
+  PasswordToggleFieldToggle,
+  PasswordToggleFieldSlot,
+  PasswordToggleFieldIcon,
+  //
+  PasswordToggleField as Root,
+  PasswordToggleFieldInput as Input,
+  PasswordToggleFieldToggle as Toggle,
+  PasswordToggleFieldSlot as Slot,
+  PasswordToggleFieldIcon as Icon,
+};
+export type {
+  PasswordToggleFieldProps,
+  PasswordToggleFieldInputProps,
+  PasswordToggleFieldToggleProps,
+  PasswordToggleFieldIconProps,
+  PasswordToggleFieldSlotProps,
+};
+
+function requestIdleCallback(
+  window: Window,
+  callback: IdleRequestCallback,
+  options?: IdleRequestOptions
+): () => void {
+  if ((window as any).requestIdleCallback) {
+    const id = window.requestIdleCallback(callback, options);
+    return () => {
+      window.cancelIdleCallback(id);
+    };
+  }
+  const start = Date.now();
+  const id = window.setTimeout(() => {
+    const timeRemaining = () => Math.max(0, 50 - (Date.now() - start));
+    callback({ didTimeout: false, timeRemaining });
+  }, 1);
+  return () => {
+    window.clearTimeout(id);
+  };
+}
diff --git a/packages/react/password-toggle-field/tsconfig.json b/packages/react/password-toggle-field/tsconfig.json
new file mode 100644
index 00000000..ecf7f700
--- /dev/null
+++ b/packages/react/password-toggle-field/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "@repo/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "types": ["@repo/typescript-config/react-library"]
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/react/radix-ui/package.json b/packages/react/radix-ui/package.json
index c7c359fe..43167a2a 100644
--- a/packages/react/radix-ui/package.json
+++ b/packages/react/radix-ui/package.json
@@ -78,6 +78,7 @@
     "@radix-ui/react-menubar": "workspace:*",
     "@radix-ui/react-navigation-menu": "workspace:*",
     "@radix-ui/react-one-time-password-field": "workspace:*",
+    "@radix-ui/react-password-toggle-field": "workspace:*",
     "@radix-ui/react-popover": "workspace:*",
     "@radix-ui/react-popper": "workspace:*",
     "@radix-ui/react-portal": "workspace:*",
diff --git a/packages/react/radix-ui/src/index.ts b/packages/react/radix-ui/src/index.ts
index 1a9caece..5c4dee05 100644
--- a/packages/react/radix-ui/src/index.ts
+++ b/packages/react/radix-ui/src/index.ts
@@ -15,6 +15,7 @@ export * as Label from '@radix-ui/react-label';
 export * as Menubar from '@radix-ui/react-menubar';
 export * as NavigationMenu from '@radix-ui/react-navigation-menu';
 export * as unstable_OneTimePasswordField from '@radix-ui/react-one-time-password-field';
+export * as unstable_PasswordToggleField from '@radix-ui/react-password-toggle-field';
 export * as Popover from '@radix-ui/react-popover';
 export * as Portal from '@radix-ui/react-portal';
 export * as Progress from '@radix-ui/react-progress';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 576ef76a..fe33b15c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -208,6 +208,9 @@ importers:
       '@storybook/manager-api':
         specifier: ^8.6.12
         version: 8.6.12(storybook@8.6.12(prettier@3.5.3))
+      '@storybook/preview-api':
+        specifier: ^8.6.12
+        version: 8.6.12(storybook@8.6.12(prettier@3.5.3))
       '@storybook/react':
         specifier: ^8.6.12
         version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.12(prettier@3.5.3)))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@8.6.12(prettier@3.5.3))(typescript@5.8.3)
@@ -1652,6 +1655,61 @@ importers:
         specifier: ^5.7.3
         version: 5.8.3
 
+  packages/react/password-toggle-field:
+    dependencies:
+      '@radix-ui/primitive':
+        specifier: workspace:*
+        version: link:../../core/primitive
+      '@radix-ui/react-compose-refs':
+        specifier: workspace:*
+        version: link:../compose-refs
+      '@radix-ui/react-context':
+        specifier: workspace:*
+        version: link:../context
+      '@radix-ui/react-id':
+        specifier: workspace:*
+        version: link:../id
+      '@radix-ui/react-primitive':
+        specifier: workspace:*
+        version: link:../primitive
+      '@radix-ui/react-use-controllable-state':
+        specifier: workspace:*
+        version: link:../use-controllable-state
+      '@radix-ui/react-use-effect-event':
+        specifier: workspace:*
+        version: link:../use-effect-event
+      '@radix-ui/react-use-is-hydrated':
+        specifier: workspace:*
+        version: link:../use-is-hydrated
+    devDependencies:
+      '@repo/builder':
+        specifier: workspace:*
+        version: link:../../../internal/builder
+      '@repo/eslint-config':
+        specifier: workspace:*
+        version: link:../../../internal/eslint-config
+      '@repo/typescript-config':
+        specifier: workspace:*
+        version: link:../../../internal/typescript-config
+      '@types/react':
+        specifier: ^19.0.7
+        version: 19.1.0
+      '@types/react-dom':
+        specifier: ^19.0.3
+        version: 19.1.1(@types/react@19.1.0)
+      eslint:
+        specifier: ^9.18.0
+        version: 9.24.0
+      react:
+        specifier: ^19.0.0
+        version: 19.1.0
+      react-dom:
+        specifier: ^19.0.0
+        version: 19.1.0(react@19.1.0)
+      typescript:
+        specifier: ^5.7.3
+        version: 5.8.3
+
   packages/react/popover:
     dependencies:
       '@radix-ui/primitive':
@@ -2075,6 +2133,9 @@ importers:
       '@radix-ui/react-one-time-password-field':
         specifier: workspace:*
         version: link:../one-time-password-field
+      '@radix-ui/react-password-toggle-field':
+        specifier: workspace:*
+        version: link:../password-toggle-field
       '@radix-ui/react-popover':
         specifier: workspace:*
         version: link:../popover


Test patch:
diff --git a/apps/ssr-testing/app/layout.tsx b/apps/ssr-testing/app/layout.tsx
index 39ffcb02..d197088b 100644
--- a/apps/ssr-testing/app/layout.tsx
+++ b/apps/ssr-testing/app/layout.tsx
@@ -25,6 +25,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
               <Link href="/menubar">Menubar</Link>
               <Link href="/navigation-menu">NavigationMenu</Link>
               <Link href="/one-time-password-field">OneTimePasswordField</Link>
+              <Link href="/password-toggle-field">PasswordToggleField</Link>
               <Link href="/popover">Popover</Link>
               <Link href="/portal">Portal</Link>
               <Link href="/progress">Progress</Link>
diff --git a/apps/ssr-testing/app/password-toggle-field/page.tsx b/apps/ssr-testing/app/password-toggle-field/page.tsx
new file mode 100644
index 00000000..4b6ce561
--- /dev/null
+++ b/apps/ssr-testing/app/password-toggle-field/page.tsx
@@ -0,0 +1,61 @@
+import * as React from 'react';
+import { unstable_PasswordToggleField as PasswordToggleField } from 'radix-ui';
+
+export default function Page() {
+  return (
+    <div style={{ display: 'flex', gap: '1em', flexDirection: 'column' }}>
+      <div>
+        <PasswordToggleField.Root>
+          <PasswordToggleField.Input />
+          <PasswordToggleField.Toggle>
+            <PasswordToggleField.Slot visible="Hide" hidden="Show" />
+          </PasswordToggleField.Toggle>
+        </PasswordToggleField.Root>
+      </div>
+      <div>
+        <PasswordToggleField.Root>
+          <PasswordToggleField.Input />
+          <PasswordToggleField.Toggle>
+            <PasswordToggleField.Icon visible={<EyeOpenIcon />} hidden={<EyeClosedIcon />} />
+          </PasswordToggleField.Toggle>
+        </PasswordToggleField.Root>
+      </div>
+    </div>
+  );
+}
+
+function EyeClosedIcon() {
+  return (
+    <svg
+      width="15"
+      height="15"
+      viewBox="0 0 15 15"
+      fill="currentColor"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <path
+        d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 10.9999 7.50001 10.9999C6.99965 10.9999 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.3989 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 9.99991 7.50001 9.99991C10.3078 9.99991 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
+        fillRule="evenodd"
+        clipRule="evenodd"
+      />
+    </svg>
+  );
+}
+
+function EyeOpenIcon() {
+  return (
+    <svg
+      width="15"
+      height="15"
+      viewBox="0 0 15 15"
+      fill="currentColor"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <path
+        d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.50001C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
+        fillRule="evenodd"
+        clipRule="evenodd"
+      />
+    </svg>
+  );
+}
diff --git a/packages/react/checkbox/src/checkbox.test.tsx b/packages/react/checkbox/src/checkbox.test.tsx
index da6a1570..51e79ea0 100644
--- a/packages/react/checkbox/src/checkbox.test.tsx
+++ b/packages/react/checkbox/src/checkbox.test.tsx
@@ -1,8 +1,8 @@
 import * as React from 'react';
 import { axe } from 'vitest-axe';
 import type { RenderResult } from '@testing-library/react';
-import { cleanup, render, fireEvent } from '@testing-library/react';
-import { Checkbox, CheckboxIndicator } from './checkbox';
+import { cleanup, render, fireEvent, screen, waitFor, act } from '@testing-library/react';
+import * as Checkbox from '.';
 import { afterEach, describe, it, beforeEach, vi, expect } from 'vitest';
 
 const CHECKBOX_ROLE = 'checkbox';
@@ -20,184 +20,464 @@ global.ResizeObserver = class ResizeObserver {
   disconnect() {}
 };
 
-describe('given a default Checkbox', () => {
-  let rendered: RenderResult;
-  let checkbox: HTMLElement;
-  let indicator: HTMLElement | null;
-
-  beforeEach(() => {
-    rendered = render(<CheckboxTest />);
-    checkbox = rendered.getByRole(CHECKBOX_ROLE);
-    indicator = rendered.queryByTestId(INDICATOR_TEST_ID);
-  });
-
+describe('Checkbox', () => {
   afterEach(cleanup);
 
-  it('should have no accessibility violations', async () => {
-    expect(await axe(rendered.container)).toHaveNoViolations();
-  });
-
-  describe('when clicking the checkbox', () => {
-    beforeEach(async () => {
-      fireEvent.click(checkbox);
-      indicator = rendered.queryByTestId(INDICATOR_TEST_ID);
+  describe('given a default Checkbox', () => {
+    let rendered: RenderResult;
+    beforeEach(() => {
+      rendered = render(
+        <Checkbox.unstable_Provider>
+          <Checkbox.unstable_Trigger aria-label="basic checkbox">
+            <Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
+          </Checkbox.unstable_Trigger>
+        </Checkbox.unstable_Provider>
+      );
     });
 
-    it('should render a visible indicator', () => {
-      expect(indicator).toBeVisible();
+    it('should have no accessibility violations', async () => {
+      expect(await axe(rendered.container)).toHaveNoViolations();
     });
 
-    describe('and clicking the checkbox again', () => {
-      beforeEach(async () => {
-        fireEvent.click(checkbox);
-      });
+    it('should toggle the indicator when clicked', async () => {
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+      await act(async () => fireEvent.click(checkbox));
 
-      it('should remove the indicator', () => {
-        expect(indicator).not.toBeInTheDocument();
-      });
+      let indicator = screen.queryByTestId(INDICATOR_TEST_ID);
+      expect(indicator).toBeVisible();
+
+      await act(async () => fireEvent.click(checkbox));
+      indicator = screen.queryByTestId(INDICATOR_TEST_ID);
+      expect(indicator).not.toBeInTheDocument();
     });
   });
-});
 
-describe('given a disabled Checkbox', () => {
-  let rendered: RenderResult;
+  describe('given a disabled Checkbox', () => {
+    let rendered: RenderResult;
+    beforeEach(() => {
+      rendered = render(
+        <Checkbox.unstable_Provider disabled>
+          <Checkbox.unstable_Trigger aria-label="basic checkbox">
+            <Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
+          </Checkbox.unstable_Trigger>
+        </Checkbox.unstable_Provider>
+      );
+    });
 
-  beforeEach(() => {
-    rendered = render(<CheckboxTest disabled />);
-  });
+    it('should have no accessibility violations', async () => {
+      expect(await axe(rendered.container)).toHaveNoViolations();
+    });
 
-  afterEach(cleanup);
+    it('should not toggle the indicator when clicked', async () => {
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+
+      await act(async () => fireEvent.click(checkbox));
+      const indicator = screen.queryByTestId(INDICATOR_TEST_ID);
+      expect(indicator).not.toBeInTheDocument();
 
-  it('should have no accessibility violations', async () => {
-    expect(await axe(rendered.container)).toHaveNoViolations();
+      await act(async () => fireEvent.click(checkbox));
+      expect(indicator).not.toBeInTheDocument();
+    });
   });
-});
 
-describe('given an uncontrolled `checked` Checkbox', () => {
-  let rendered: RenderResult;
-  let checkbox: HTMLElement;
-  let indicator: HTMLElement | null;
-  const onCheckedChange = vi.fn();
+  describe('given an uncontrolled `checked` Checkbox', () => {
+    const onCheckedChange = vi.fn();
+    let rendered: RenderResult;
 
-  beforeEach(() => {
-    rendered = render(<CheckboxTest defaultChecked onCheckedChange={onCheckedChange} />);
-    checkbox = rendered.getByRole(CHECKBOX_ROLE);
-    indicator = rendered.queryByTestId(INDICATOR_TEST_ID);
-  });
+    beforeEach(() => {
+      rendered = render(
+        <Checkbox.unstable_Provider defaultChecked onCheckedChange={onCheckedChange}>
+          <Checkbox.unstable_Trigger aria-label="basic checkbox">
+            <Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
+          </Checkbox.unstable_Trigger>
+        </Checkbox.unstable_Provider>
+      );
+    });
 
-  afterEach(cleanup);
+    afterEach(cleanup);
 
-  it('should have no accessibility violations', async () => {
-    expect(await axe(rendered.container)).toHaveNoViolations();
-  });
+    it('should have no accessibility violations', async () => {
+      expect(await axe(rendered.container)).toHaveNoViolations();
+    });
 
-  describe('when clicking the checkbox', () => {
-    beforeEach(async () => {
-      fireEvent.click(checkbox);
+    it('should render a visible indicator', () => {
+      const indicator = screen.queryByTestId(INDICATOR_TEST_ID);
+      expect(indicator).toBeVisible();
     });
 
-    it('should remove the indicator', () => {
+    it('should toggle the indicator when clicked', async () => {
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+      await act(async () => fireEvent.click(checkbox));
+      let indicator = screen.queryByTestId(INDICATOR_TEST_ID);
       expect(indicator).not.toBeInTheDocument();
+
+      await act(async () => fireEvent.click(checkbox));
+      indicator = screen.queryByTestId(INDICATOR_TEST_ID);
+      expect(indicator).toBeVisible();
     });
 
     it('should call `onCheckedChange` prop', () => {
-      expect(onCheckedChange).toHaveBeenCalled();
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+      fireEvent.click(checkbox);
+      waitFor(() => {
+        expect(onCheckedChange).toHaveBeenCalledWith(false);
+      });
     });
   });
-});
 
-describe('given a controlled `checked` Checkbox', () => {
-  let rendered: RenderResult;
-  let checkbox: HTMLElement;
-  const onCheckedChange = vi.fn();
+  describe('given a controlled Checkbox', () => {
+    const onCheckedChange = vi.fn();
+    let rendered: RenderResult;
 
-  beforeEach(() => {
-    rendered = render(<CheckboxTest checked onCheckedChange={onCheckedChange} />);
-    checkbox = rendered.getByRole(CHECKBOX_ROLE);
-  });
-
-  afterEach(cleanup);
+    function ControlledCheckbox() {
+      const [checked, setChecked] = React.useState(false);
+      const [blockToggle, setBlockToggle] = React.useState(false);
+      return (
+        <div>
+          <Checkbox.unstable_Provider
+            checked={checked}
+            onCheckedChange={(checked) => {
+              onCheckedChange(checked);
+              if (!blockToggle) {
+                setChecked(checked);
+              }
+            }}
+          >
+            <Checkbox.unstable_Trigger aria-label="basic checkbox">
+              <Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
+            </Checkbox.unstable_Trigger>
+          </Checkbox.unstable_Provider>
+          <button type="button" onClick={() => setChecked((prev) => !prev)}>
+            Toggle checkbox
+          </button>
+          <button type="button" onClick={() => setBlockToggle((prev) => !prev)}>
+            {blockToggle ? 'Unblock' : 'Block'} checkbox
+          </button>
+        </div>
+      );
+    }
 
-  describe('when clicking the checkbox', () => {
     beforeEach(() => {
-      fireEvent.click(checkbox);
+      rendered = render(<ControlledCheckbox />);
     });
 
-    it('should call `onCheckedChange` prop', () => {
-      expect(onCheckedChange).toHaveBeenCalled();
+    it('should have no accessibility violations', async () => {
+      expect(await axe(rendered.container)).toHaveNoViolations();
+    });
+
+    it('should toggle the indicator', async () => {
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+      await act(async () => fireEvent.click(checkbox));
+      const indicator = screen.queryByTestId(INDICATOR_TEST_ID);
+      expect(indicator).toBeVisible();
+    });
+
+    it('should call `onCheckedChange` prop', async () => {
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+      await act(async () => fireEvent.click(checkbox));
+      expect(onCheckedChange).toHaveBeenCalledWith(true);
+    });
+
+    it('should not toggle unless state is updated', async () => {
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+      const blocker = screen.getByText('Block checkbox');
+      await act(async () => fireEvent.click(blocker));
+      await act(async () => fireEvent.click(checkbox));
+      const indicator = screen.queryByTestId(INDICATOR_TEST_ID);
+      expect(indicator).not.toBeInTheDocument();
     });
   });
-});
 
-describe('given an uncontrolled Checkbox in form', () => {
-  afterEach(cleanup);
+  describe('given an uncontrolled Checkbox in form with bubble input', () => {
+    const onChange = vi.fn();
 
-  describe('when clicking the checkbox', () => {
-    it('should receive change event with target `defaultChecked` same as the `defaultChecked` prop of Checkbox', () =>
-      new Promise((done) => {
-        const rendered = render(
-          <form
-            onChange={(event) => {
-              const target = event.target as HTMLInputElement;
-              expect(target.defaultChecked).toBe(true);
-            }}
-          >
-            <CheckboxTest defaultChecked />
-          </form>
-        );
-        const checkbox = rendered.getByRole(CHECKBOX_ROLE);
-        fireEvent.click(checkbox);
-        rendered.rerender(
+    it('should receive change event with next state', async () => {
+      render(
+        <form
+          onChange={(event) => {
+            const target = event.target as HTMLInputElement;
+            onChange(target.checked);
+          }}
+        >
+          <Checkbox.unstable_Provider>
+            <Checkbox.unstable_Trigger aria-label="basic checkbox">
+              <Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
+            </Checkbox.unstable_Trigger>
+            <Checkbox.unstable_BubbleInput />
+          </Checkbox.unstable_Provider>
+        </form>
+      );
+
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+      act(() => fireEvent.click(checkbox));
+      expect(onChange).toHaveBeenCalledWith(true);
+    });
+
+    describe('when `defaultChecked` is true', () => {
+      it('should receive change event with next state', async () => {
+        console.log(parseInt(React.version));
+        render(
           <form
             onChange={(event) => {
               const target = event.target as HTMLInputElement;
-              expect(target.defaultChecked).toBe(false);
-              done(null);
+              onChange(target.checked);
             }}
           >
-            <CheckboxTest defaultChecked={false} />
+            <Checkbox.unstable_Provider defaultChecked>
+              <Checkbox.unstable_Trigger aria-label="basic checkbox">
+                <Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
+              </Checkbox.unstable_Trigger>
+              <Checkbox.unstable_BubbleInput />
+            </Checkbox.unstable_Provider>
           </form>
         );
-        fireEvent.click(checkbox);
-      }));
+
+        const checkbox = screen.getByRole(CHECKBOX_ROLE);
+        act(() => fireEvent.click(checkbox));
+        expect(onChange).toHaveBeenCalledWith(false);
+      });
+    });
+  });
+
+  describe('given a controlled Checkbox in form with bubble input', () => {
+    const onChange = vi.fn();
+
+    function ControlledCheckbox() {
+      const [checked, setChecked] = React.useState(false);
+      return (
+        <>
+          <Checkbox.unstable_Provider checked={checked} onCheckedChange={setChecked as any}>
+            <Checkbox.unstable_Trigger aria-label="basic checkbox">
+              <Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
+            </Checkbox.unstable_Trigger>
+            <Checkbox.unstable_BubbleInput />
+          </Checkbox.unstable_Provider>
+          <button type="button" onClick={() => setChecked((prev) => !prev)}>
+            Toggle checkbox
+          </button>
+        </>
+      );
+    }
+
+    it('should receive change event with controlled state when clicked', async () => {
+      render(
+        <form
+          onChange={(event) => {
+            const target = event.target as HTMLInputElement;
+            onChange(target.checked);
+          }}
+        >
+          <ControlledCheckbox />
+        </form>
+      );
+
+      const checkbox = screen.getByRole(CHECKBOX_ROLE);
+      act(() => fireEvent.click(checkbox));
+      expect(onChange).toHaveBeenCalledWith(true);
+
+      act(() => fireEvent.click(checkbox));
+      expect(onChange).toHaveBeenCalledWith(false);
+    });
+
+    it('should receive change event with controlled state when set externally', async () => {
+      render(
+        <form
+          onChange={(event) => {
+            const target = event.target as HTMLInputElement;
+            onChange(target.checked);
+          }}
+        >
+          <ControlledCheckbox />
+        </form>
+      );
+
+      const toggleButton = screen.getByText('Toggle checkbox');
+      act(() => fireEvent.click(toggleButton));
+      expect(onChange).toHaveBeenCalledWith(false);
+    });
   });
 });
 
-describe('given a controlled Checkbox in a form', () => {
-  afterEach(cleanup);
+describe('Legacy Checkbox', () => {
+  describe('given a default Checkbox', () => {
+    let rendered: RenderResult;
+    let checkbox: HTMLElement;
+    let indicator: HTMLElement | null;
 
-  describe('when clicking the checkbox', () => {
-    it('should receive change event with target `defaultChecked` same as initial value of `checked` of Checkbox', () =>
-      new Promise((done) => {
-        const rendered = render(
-          <form
-            onChange={(event) => {
-              const target = event.target as HTMLInputElement;
-              expect(target.defaultChecked).toBe(true);
-            }}
-          >
-            <CheckboxTest checked />
-          </form>
-        );
-        const checkbox = rendered.getByRole(CHECKBOX_ROLE);
+    beforeEach(() => {
+      rendered = render(<LegacyCheckbox />);
+      checkbox = rendered.getByRole(CHECKBOX_ROLE);
+      indicator = rendered.queryByTestId(INDICATOR_TEST_ID);
+    });
+
+    afterEach(cleanup);
+
+    it('should have no accessibility violations', async () => {
+      expect(await axe(rendered.container)).toHaveNoViolations();
+    });
+
+    describe('when clicking the checkbox', () => {
+      beforeEach(async () => {
         fireEvent.click(checkbox);
-        rendered.rerender(
-          <form
-            onChange={(event) => {
-              const target = event.target as HTMLInputElement;
-              expect(target.defaultChecked).toBe(true);
-              done(null);
-            }}
-          >
-            <CheckboxTest checked={false} />
-          </form>
-        );
+        indicator = rendered.queryByTestId(INDICATOR_TEST_ID);
+      });
+
+      it('should render a visible indicator', () => {
+        expect(indicator).toBeVisible();
+      });
+
+      describe('and clicking the checkbox again', () => {
+        beforeEach(async () => {
+          fireEvent.click(checkbox);
+        });
+
+        it('should remove the indicator', () => {
+          expect(indicator).not.toBeInTheDocument();
+        });
+      });
+    });
+  });
+
+  describe('given a disabled Checkbox', () => {
+    let rendered: RenderResult;
+
+    beforeEach(() => {
+      rendered = render(<LegacyCheckbox disabled />);
+    });
+
+    afterEach(cleanup);
+
+    it('should have no accessibility violations', async () => {
+      expect(await axe(rendered.container)).toHaveNoViolations();
+    });
+  });
+
+  describe('given an uncontrolled `checked` Checkbox', () => {
+    let rendered: RenderResult;
+    let checkbox: HTMLElement;
+    let indicator: HTMLElement | null;
+    const onCheckedChange = vi.fn();
+
+    beforeEach(() => {
+      rendered = render(<LegacyCheckbox defaultChecked onCheckedChange={onCheckedChange} />);
+      checkbox = rendered.getByRole(CHECKBOX_ROLE);
+      indicator = rendered.queryByTestId(INDICATOR_TEST_ID);
+    });
+
+    afterEach(cleanup);
+
+    it('should have no accessibility violations', async () => {
+      expect(await axe(rendered.container)).toHaveNoViolations();
+    });
+
+    describe('when clicking the checkbox', () => {
+      beforeEach(async () => {
+        fireEvent.click(checkbox);
+      });
+
+      it('should remove the indicator', () => {
+        expect(indicator).not.toBeInTheDocument();
+      });
+
+      it('should call `onCheckedChange` prop', () => {
+        expect(onCheckedChange).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('given a controlled `checked` Checkbox', () => {
+    let rendered: RenderResult;
+    let checkbox: HTMLElement;
+    const onCheckedChange = vi.fn();
+
+    beforeEach(() => {
+      rendered = render(<LegacyCheckbox checked onCheckedChange={onCheckedChange} />);
+      checkbox = rendered.getByRole(CHECKBOX_ROLE);
+    });
+
+    afterEach(cleanup);
+
+    describe('when clicking the checkbox', () => {
+      beforeEach(() => {
         fireEvent.click(checkbox);
-      }));
+      });
+
+      it('should call `onCheckedChange` prop', () => {
+        expect(onCheckedChange).toHaveBeenCalled();
+      });
+    });
+  });
+
+  describe('given an uncontrolled Checkbox in form', () => {
+    afterEach(cleanup);
+
+    describe('when clicking the checkbox', () => {
+      it('should receive change event with target `defaultChecked` same as the `defaultChecked` prop of Checkbox', () =>
+        new Promise((done) => {
+          const rendered = render(
+            <form
+              onChange={(event) => {
+                const target = event.target as HTMLInputElement;
+                expect(target.defaultChecked).toBe(true);
+              }}
+            >
+              <LegacyCheckbox defaultChecked />
+            </form>
+          );
+          const checkbox = rendered.getByRole(CHECKBOX_ROLE);
+          fireEvent.click(checkbox);
+          rendered.rerender(
+            <form
+              onChange={(event) => {
+                const target = event.target as HTMLInputElement;
+                expect(target.defaultChecked).toBe(false);
+                done(null);
+              }}
+            >
+              <LegacyCheckbox defaultChecked={false} />
+            </form>
+          );
+          fireEvent.click(checkbox);
+        }));
+    });
+  });
+
+  describe('given a controlled Checkbox in a form', () => {
+    afterEach(cleanup);
+
+    describe('when clicking the checkbox', () => {
+      it('should receive change event with target `defaultChecked` same as initial value of `checked` of Checkbox', () =>
+        new Promise((done) => {
+          const rendered = render(
+            <form
+              onChange={(event) => {
+                const target = event.target as HTMLInputElement;
+                expect(target.defaultChecked).toBe(true);
+              }}
+            >
+              <LegacyCheckbox checked />
+            </form>
+          );
+          const checkbox = rendered.getByRole(CHECKBOX_ROLE);
+          fireEvent.click(checkbox);
+          rendered.rerender(
+            <form
+              onChange={(event) => {
+                const target = event.target as HTMLInputElement;
+                expect(target.defaultChecked).toBe(true);
+                done(null);
+              }}
+            >
+              <LegacyCheckbox checked={false} />
+            </form>
+          );
+          fireEvent.click(checkbox);
+        }));
+    });
   });
 });
 
-function CheckboxTest(props: React.ComponentProps<typeof Checkbox>) {
+function LegacyCheckbox(props: React.ComponentProps<typeof Checkbox.Root>) {
   const containerRef = React.useRef<HTMLDivElement>(null);
   React.useEffect(() => {
     // We use the `hidden` attribute to hide the nested input from both sighted users and the
@@ -210,9 +490,9 @@ function CheckboxTest(props: React.ComponentProps<typeof Checkbox>) {
   }, []);
   return (
     <div ref={containerRef}>
-      <Checkbox aria-label="basic checkbox" {...props}>
-        <CheckboxIndicator data-testid={INDICATOR_TEST_ID} />
-      </Checkbox>
+      <Checkbox.Root aria-label="basic checkbox" {...props}>
+        <Checkbox.Indicator data-testid={INDICATOR_TEST_ID} />
+      </Checkbox.Root>
     </div>
   );
 }
diff --git a/packages/react/password-toggle-field/src/password-toggle-field.test.tsx b/packages/react/password-toggle-field/src/password-toggle-field.test.tsx
new file mode 100644
index 00000000..68bf5764
--- /dev/null
+++ b/packages/react/password-toggle-field/src/password-toggle-field.test.tsx
@@ -0,0 +1,219 @@
+import { axe } from 'vitest-axe';
+import type { RenderResult } from '@testing-library/react';
+import { act, cleanup, render, screen } from '@testing-library/react';
+import * as PasswordToggleField from './password-toggle-field';
+import { afterEach, describe, it, beforeEach, expect } from 'vitest';
+import { userEvent, type UserEvent } from '@testing-library/user-event';
+
+describe('given a default PasswordToggleField', () => {
+  let rendered: RenderResult;
+  let user: UserEvent;
+
+  afterEach(cleanup);
+
+  beforeEach(() => {
+    user = userEvent.setup();
+    rendered = render(
+      <>
+        <label htmlFor="password">Password</label>
+        <PasswordToggleField.Root>
+          <PasswordToggleField.Input id="password" />
+          <PasswordToggleField.Toggle>
+            <PasswordToggleField.Slot visible="Hide" hidden="Show" />
+          </PasswordToggleField.Toggle>
+        </PasswordToggleField.Root>
+      </>
+    );
+  });
+
+  afterEach(cleanup);
+
+  it('should have no accessibility violations', async () => {
+    expect(await axe(rendered.container)).toHaveNoViolations();
+  });
+
+  it('should initially be hidden', () => {
+    const toggle = screen.getByRole('button', { name: 'Show' });
+    expect(toggle.textContent).toBe('Show');
+  });
+
+  it('toggles the children when clicked', async () => {
+    const toggle = screen.getByRole('button', { name: 'Show' });
+    await act(async () => await user.click(toggle));
+    expect(toggle.textContent).toBe('Hide');
+  });
+
+  it('should initially render input as `type=password`', async () => {
+    const input = screen.getByLabelText<HTMLInputElement>('Password');
+    expect(input.type).toBe('password');
+  });
+
+  it('should initially render input as `type=text` when toggled', async () => {
+    const input = screen.getByLabelText<HTMLInputElement>('Password');
+    const toggle = screen.getByRole('button', { name: 'Show' });
+    await act(async () => await user.click(toggle));
+    expect(input.type).toBe('text');
+  });
+
+  it('should re-focus the input after toggling', async () => {
+    const input = screen.getByLabelText<HTMLInputElement>('Password');
+    const toggle = screen.getByRole('button', { name: 'Show' });
+    await act(async () => await user.click(toggle));
+    expect(document.activeElement).toBe(input);
+  });
+
+  it("should restore the input's selection after toggling", async () => {
+    const input = screen.getByLabelText<HTMLInputElement>('Password');
+    const toggle = screen.getByRole('button', { name: 'Show' });
+    await act(async () => await user.click(input));
+    await act(async () => await user.type(input, 'p'));
+    await act(async () => await user.click(toggle));
+    // selection should be at the end of the input value
+    expect(input.selectionStart).toBe(1);
+    expect(input.selectionEnd).toBe(1);
+
+    await act(async () => await user.type(input, 'assword'));
+    input.selectionStart = 0;
+    input.selectionEnd = 4;
+
+    await act(async () => await user.click(toggle));
+    expect(input.selectionStart).toBe(0);
+    expect(input.selectionEnd).toBe(4);
+  });
+});
+
+describe('given a PasswordToggleField with an Icon', () => {
+  let rendered: RenderResult;
+  let user: UserEvent;
+
+  afterEach(cleanup);
+
+  beforeEach(() => {
+    user = userEvent.setup();
+    rendered = render(
+      <>
+        <label htmlFor="password">Password</label>
+        <PasswordToggleField.Root>
+          <PasswordToggleField.Input id="password" />
+          <PasswordToggleField.Toggle>
+            <PasswordToggleField.Icon
+              visible={<EyeOpenIcon data-testid="open" />}
+              hidden={<EyeClosedIcon data-testid="closed" />}
+            />
+          </PasswordToggleField.Toggle>
+        </PasswordToggleField.Root>
+      </>
+    );
+  });
+
+  afterEach(cleanup);
+
+  it('should have no accessibility violations', async () => {
+    expect(await axe(rendered.container)).toHaveNoViolations();
+  });
+
+  it('should initially be hidden', () => {
+    const icon = screen.getByTestId('closed');
+    expect(icon).toBeVisible();
+    expect(screen.queryByTestId('open')).not.toBeInTheDocument();
+  });
+
+  it('toggles the icon when clicked', async () => {
+    const toggle = screen.getByRole('button', { name: 'Show password' });
+    await act(async () => await user.click(toggle));
+    expect(screen.getByTestId('open')).toBeVisible();
+    expect(screen.queryByTestId('closed')).not.toBeInTheDocument();
+  });
+
+  it('should initially render input as `type=password`', async () => {
+    const input = screen.getByLabelText<HTMLInputElement>('Password');
+    expect(input.type).toBe('password');
+  });
+
+  it('should initially render input as `type=text` when toggled', async () => {
+    const input = screen.getByLabelText<HTMLInputElement>('Password');
+    const toggle = screen.getByRole('button', { name: 'Show password' });
+    await act(async () => await user.click(toggle));
+    expect(input.type).toBe('text');
+  });
+
+  it('should retain its value when toggled', async () => {
+    const input = screen.getByLabelText('Password');
+    const toggle = screen.getByRole('button', { name: 'Show password' });
+    await act(async () => await user.type(input, 'pass123'));
+    await act(async () => await user.click(toggle));
+    expect(input).toHaveValue('pass123');
+  });
+});
+
+describe('given a PasswordToggleField in a form', () => {
+  let user: UserEvent;
+
+  afterEach(cleanup);
+
+  beforeEach(() => {
+    user = userEvent.setup();
+    render(
+      <form onSubmit={(e) => e.preventDefault()}>
+        <label htmlFor="password">Password</label>
+        <PasswordToggleField.Root>
+          <PasswordToggleField.Input id="password" />
+          <PasswordToggleField.Toggle>
+            <PasswordToggleField.Slot visible="Hide" hidden="Show" />
+          </PasswordToggleField.Toggle>
+        </PasswordToggleField.Root>
+        <button>Submit</button>
+      </form>
+    );
+  });
+
+  afterEach(cleanup);
+
+  it('should reset visibility to hidden after submission', async () => {
+    const toggle = screen.getByRole('button', { name: 'Show' });
+    const input = screen.getByLabelText<HTMLInputElement>('Password');
+    await act(() => user.click(toggle));
+    expect(input.type).toBe('text');
+    const submitButton = screen.getByText('Submit');
+    await act(() => user.click(submitButton));
+    expect(input.type).toBe('password');
+  });
+});
+
+function EyeClosedIcon(props: React.SVGProps<SVGSVGElement>) {
+  return (
+    <svg
+      width="15"
+      height="15"
+      viewBox="0 0 15 15"
+      fill="currentColor"
+      xmlns="http://www.w3.org/2000/svg"
+      {...props}
+    >
+      <path
+        d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 10.9999 7.50001 10.9999C6.99965 10.9999 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.3989 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 9.99991 7.50001 9.99991C10.3078 9.99991 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
+        fillRule="evenodd"
+        clipRule="evenodd"
+      />
+    </svg>
+  );
+}
+
+function EyeOpenIcon(props: React.SVGProps<SVGSVGElement>) {
+  return (
+    <svg
+      width="15"
+      height="15"
+      viewBox="0 0 15 15"
+      fill="currentColor"
+      xmlns="http://www.w3.org/2000/svg"
+      {...props}
+    >
+      <path
+        d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.50001C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
+        fillRule="evenodd"
+        clipRule="evenodd"
+      />
+    </svg>
+  );
+}