Repository: radix-ui/primitives
Pull request #3464 (2025-04-13 04:55:49)

Add new `PasswordToggleField` primitive
See Changeset for details

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

Latest commit: c7bd2344d7073d9cbb0c5f22eebcf8403c43c3cf

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/password-toggle-field?filename=.changeset/real-planes-reply.md&value=---%0A%22%40repo%2Fstorybook%22%3A%20patch%0A%22%40radix-ui%2Freact-password-toggle-field%22%3A%20patch%0A%22radix-ui%22%3A%20patch%0A---%0A%0AAdd%20new%20%60PasswordToggleField%60%20primitive%0A)



Patch:
diff --git a/.changeset/famous-socks-deny.md b/.changeset/famous-socks-deny.md
new file mode 100644
index 00000000..5caa182d
--- /dev/null
+++ b/.changeset/famous-socks-deny.md
@@ -0,0 +1,5 @@
+---
+'@radix-ui/react-slot': patch
+---
+
+use stable composed refs in SlotClone
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/.changeset/yellow-geese-knock.md b/.changeset/yellow-geese-knock.md
deleted file mode 100644
index c2c4b4e6..00000000
--- a/.changeset/yellow-geese-knock.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@radix-ui/react-presence': patch
----
-
-Fix memory leak in Presence
diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml
index 8aa480da..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:
@@ -36,7 +39,7 @@ jobs:
         run: pnpm build
 
       - name: Write .npmrc
-        run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_AUTOMATION_FROM_CHANCE }}" > .npmrc
+        run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc
 
       - name: Update snapshot version
         run: pnpm run bump:next
@@ -50,5 +53,3 @@ jobs:
 
       - name: Publish snapshot
         run: pnpm run release:next
-        env:
-          YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTOMATION_FROM_CHANCE }}
diff --git a/.github/workflows/publish-stable.yml b/.github/workflows/publish-stable.yml
index 783c8798..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:
@@ -34,6 +38,9 @@ jobs:
       - name: Build packages
         run: pnpm build
 
+      - name: Write .npmrc
+        run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc
+
       - name: Create Release Pull Request or Publish to npm
         id: changesets
         uses: changesets/action@v1
@@ -43,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/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/accordion/CHANGELOG.md b/packages/react/accordion/CHANGELOG.md
index bace21cf..740db812 100644
--- a/packages/react/accordion/CHANGELOG.md
+++ b/packages/react/accordion/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-accordion
 
+## 1.2.8
+
+- Updated dependencies: `@radix-ui/react-collapsible@1.1.8`
+
 ## 1.2.7
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`, `@radix-ui/react-collapsible@1.1.7`
diff --git a/packages/react/accordion/package.json b/packages/react/accordion/package.json
index 7bdf9db3..47a5df9c 100644
--- a/packages/react/accordion/package.json
+++ b/packages/react/accordion/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-accordion",
-  "version": "1.2.7",
+  "version": "1.2.8",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/alert-dialog/CHANGELOG.md b/packages/react/alert-dialog/CHANGELOG.md
index c3f4a3c1..af1a1df6 100644
--- a/packages/react/alert-dialog/CHANGELOG.md
+++ b/packages/react/alert-dialog/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-alert-dialog
 
+## 1.1.11
+
+- Updated dependencies: `@radix-ui/react-dialog@1.1.11`
+
 ## 1.1.10
 
 - Updated dependencies: `@radix-ui/react-dialog@1.1.10`
diff --git a/packages/react/alert-dialog/package.json b/packages/react/alert-dialog/package.json
index 2590427a..bdec1983 100644
--- a/packages/react/alert-dialog/package.json
+++ b/packages/react/alert-dialog/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-alert-dialog",
-  "version": "1.1.10",
+  "version": "1.1.11",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/avatar/CHANGELOG.md b/packages/react/avatar/CHANGELOG.md
index 982df1c4..abf3e869 100644
--- a/packages/react/avatar/CHANGELOG.md
+++ b/packages/react/avatar/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-avatar
 
+## 1.1.7
+
+- Updated dependencies: `@radix-ui/react-use-is-hydrated@0.1.0`
+
 ## 1.1.6
 
 - Fix breaking `useSyncExternalStore` import in Avatar
diff --git a/packages/react/avatar/package.json b/packages/react/avatar/package.json
index 3c0a208e..240c4601 100644
--- a/packages/react/avatar/package.json
+++ b/packages/react/avatar/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-avatar",
-  "version": "1.1.6",
+  "version": "1.1.7",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/checkbox/CHANGELOG.md b/packages/react/checkbox/CHANGELOG.md
index 20d8962f..57abd0c0 100644
--- a/packages/react/checkbox/CHANGELOG.md
+++ b/packages/react/checkbox/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-checkbox
 
+## 1.2.3
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.2.2
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`
diff --git a/packages/react/checkbox/package.json b/packages/react/checkbox/package.json
index 338da592..fda77d30 100644
--- a/packages/react/checkbox/package.json
+++ b/packages/react/checkbox/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-checkbox",
-  "version": "1.2.2",
+  "version": "1.2.3",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/collapsible/CHANGELOG.md b/packages/react/collapsible/CHANGELOG.md
index 220dd8d5..014f5092 100644
--- a/packages/react/collapsible/CHANGELOG.md
+++ b/packages/react/collapsible/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-collapsible
 
+## 1.1.8
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.1.7
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`
diff --git a/packages/react/collapsible/package.json b/packages/react/collapsible/package.json
index 24e3f9fc..d711cf69 100644
--- a/packages/react/collapsible/package.json
+++ b/packages/react/collapsible/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-collapsible",
-  "version": "1.1.7",
+  "version": "1.1.8",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/context-menu/CHANGELOG.md b/packages/react/context-menu/CHANGELOG.md
index ef5a605d..21dfe852 100644
--- a/packages/react/context-menu/CHANGELOG.md
+++ b/packages/react/context-menu/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-context-menu
 
+## 2.2.12
+
+- Updated dependencies: `@radix-ui/react-menu@2.1.12`
+
 ## 2.2.11
 
 - Updated dependencies: `@radix-ui/react-menu@2.1.11`
diff --git a/packages/react/context-menu/package.json b/packages/react/context-menu/package.json
index 1790e3a6..874bf36d 100644
--- a/packages/react/context-menu/package.json
+++ b/packages/react/context-menu/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-context-menu",
-  "version": "2.2.11",
+  "version": "2.2.12",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/dialog/CHANGELOG.md b/packages/react/dialog/CHANGELOG.md
index 1b9fa5cc..65f74205 100644
--- a/packages/react/dialog/CHANGELOG.md
+++ b/packages/react/dialog/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-dialog
 
+## 1.1.11
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.1.10
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`
diff --git a/packages/react/dialog/package.json b/packages/react/dialog/package.json
index 1ba955ee..2f2cd889 100644
--- a/packages/react/dialog/package.json
+++ b/packages/react/dialog/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-dialog",
-  "version": "1.1.10",
+  "version": "1.1.11",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/dropdown-menu/CHANGELOG.md b/packages/react/dropdown-menu/CHANGELOG.md
index 84472104..60e6d076 100644
--- a/packages/react/dropdown-menu/CHANGELOG.md
+++ b/packages/react/dropdown-menu/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-dropdown-menu
 
+## 2.1.12
+
+- Updated dependencies: `@radix-ui/react-menu@2.1.12`
+
 ## 2.1.11
 
 - Updated dependencies: `@radix-ui/react-menu@2.1.11`
diff --git a/packages/react/dropdown-menu/package.json b/packages/react/dropdown-menu/package.json
index 0aacd84e..5ea029ae 100644
--- a/packages/react/dropdown-menu/package.json
+++ b/packages/react/dropdown-menu/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-dropdown-menu",
-  "version": "2.1.11",
+  "version": "2.1.12",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/hover-card/CHANGELOG.md b/packages/react/hover-card/CHANGELOG.md
index 7d004218..140480c8 100644
--- a/packages/react/hover-card/CHANGELOG.md
+++ b/packages/react/hover-card/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-hover-card
 
+## 1.1.11
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.1.10
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`
diff --git a/packages/react/hover-card/package.json b/packages/react/hover-card/package.json
index dce77ffb..88f3de90 100644
--- a/packages/react/hover-card/package.json
+++ b/packages/react/hover-card/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-hover-card",
-  "version": "1.1.10",
+  "version": "1.1.11",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/menu/CHANGELOG.md b/packages/react/menu/CHANGELOG.md
index e54f357a..60d8b485 100644
--- a/packages/react/menu/CHANGELOG.md
+++ b/packages/react/menu/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-menu
 
+## 2.1.12
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 2.1.11
 
 - Updated dependencies: `@radix-ui/react-roving-focus@1.1.7`
diff --git a/packages/react/menu/package.json b/packages/react/menu/package.json
index 702a2564..e37f8377 100644
--- a/packages/react/menu/package.json
+++ b/packages/react/menu/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-menu",
-  "version": "2.1.11",
+  "version": "2.1.12",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/menubar/CHANGELOG.md b/packages/react/menubar/CHANGELOG.md
index 282665e9..ca52fee1 100644
--- a/packages/react/menubar/CHANGELOG.md
+++ b/packages/react/menubar/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-menubar
 
+## 1.1.12
+
+- Updated dependencies: `@radix-ui/react-menu@2.1.12`
+
 ## 1.1.11
 
 - Updated dependencies: `@radix-ui/react-roving-focus@1.1.7`, `@radix-ui/react-menu@2.1.11`
diff --git a/packages/react/menubar/package.json b/packages/react/menubar/package.json
index ee3be84b..c3b36aac 100644
--- a/packages/react/menubar/package.json
+++ b/packages/react/menubar/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-menubar",
-  "version": "1.1.11",
+  "version": "1.1.12",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/navigation-menu/CHANGELOG.md b/packages/react/navigation-menu/CHANGELOG.md
index d82da757..d111ecf1 100644
--- a/packages/react/navigation-menu/CHANGELOG.md
+++ b/packages/react/navigation-menu/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-navigation-menu
 
+## 1.2.10
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.2.9
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`
diff --git a/packages/react/navigation-menu/package.json b/packages/react/navigation-menu/package.json
index 4dfaaa92..99d4bb62 100644
--- a/packages/react/navigation-menu/package.json
+++ b/packages/react/navigation-menu/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-navigation-menu",
-  "version": "1.2.9",
+  "version": "1.2.10",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/one-time-password-field/CHANGELOG.md b/packages/react/one-time-password-field/CHANGELOG.md
index 42fb7304..ffd18989 100644
--- a/packages/react/one-time-password-field/CHANGELOG.md
+++ b/packages/react/one-time-password-field/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-one-time-password-field
 
+## 0.1.4
+
+- Updated dependencies: `@radix-ui/react-use-is-hydrated@0.1.0`
+
 ## 0.1.3
 
 - Fix hydration mismatch error in `OneTimePasswordField` and add opt-in `index` prop
diff --git a/packages/react/one-time-password-field/package.json b/packages/react/one-time-password-field/package.json
index 1f1a3249..b36debad 100644
--- a/packages/react/one-time-password-field/package.json
+++ b/packages/react/one-time-password-field/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-one-time-password-field",
-  "version": "0.1.3",
+  "version": "0.1.4",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
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/popover/CHANGELOG.md b/packages/react/popover/CHANGELOG.md
index 26ff4519..5dcaac4a 100644
--- a/packages/react/popover/CHANGELOG.md
+++ b/packages/react/popover/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-popover
 
+## 1.1.11
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.1.10
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`
diff --git a/packages/react/popover/package.json b/packages/react/popover/package.json
index 8724c29a..1b991e2a 100644
--- a/packages/react/popover/package.json
+++ b/packages/react/popover/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-popover",
-  "version": "1.1.10",
+  "version": "1.1.11",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/presence/CHANGELOG.md b/packages/react/presence/CHANGELOG.md
new file mode 100644
index 00000000..b916229c
--- /dev/null
+++ b/packages/react/presence/CHANGELOG.md
@@ -0,0 +1,5 @@
+# @radix-ui/react-presence
+
+## 1.1.4
+
+- Fix memory leak in Presence
diff --git a/packages/react/presence/package.json b/packages/react/presence/package.json
index a81f2c92..ba7b2cb0 100644
--- a/packages/react/presence/package.json
+++ b/packages/react/presence/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-presence",
-  "version": "1.1.3",
+  "version": "1.1.4",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/radio-group/CHANGELOG.md b/packages/react/radio-group/CHANGELOG.md
index 339660bc..fe0db2f9 100644
--- a/packages/react/radio-group/CHANGELOG.md
+++ b/packages/react/radio-group/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-radio-group
 
+## 1.3.4
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.3.3
 
 - Updated dependencies: `@radix-ui/react-roving-focus@1.1.7`
diff --git a/packages/react/radio-group/package.json b/packages/react/radio-group/package.json
index 5344c4c2..4c7e48e6 100644
--- a/packages/react/radio-group/package.json
+++ b/packages/react/radio-group/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-radio-group",
-  "version": "1.3.3",
+  "version": "1.3.4",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/radix-ui/CHANGELOG.md b/packages/react/radix-ui/CHANGELOG.md
index 46e62899..3c25020a 100644
--- a/packages/react/radix-ui/CHANGELOG.md
+++ b/packages/react/radix-ui/CHANGELOG.md
@@ -1,5 +1,9 @@
 # radix-ui
 
+## 1.3.4
+
+- Updated dependencies: `@radix-ui/react-use-is-hydrated@0.1.0`, `@radix-ui/react-presence@1.1.4`, `@radix-ui/react-avatar@1.1.7`, `@radix-ui/react-one-time-password-field@0.1.4`, `@radix-ui/react-checkbox@1.2.3`, `@radix-ui/react-collapsible@1.1.8`, `@radix-ui/react-dialog@1.1.11`, `@radix-ui/react-hover-card@1.1.11`, `@radix-ui/react-menu@2.1.12`, `@radix-ui/react-navigation-menu@1.2.10`, `@radix-ui/react-popover@1.1.11`, `@radix-ui/react-radio-group@1.3.4`, `@radix-ui/react-scroll-area@1.2.6`, `@radix-ui/react-tabs@1.1.9`, `@radix-ui/react-toast@1.2.11`, `@radix-ui/react-tooltip@1.2.4`, `@radix-ui/react-accordion@1.2.8`, `@radix-ui/react-alert-dialog@1.1.11`, `@radix-ui/react-context-menu@2.2.12`, `@radix-ui/react-dropdown-menu@2.1.12`, `@radix-ui/react-menubar@1.1.12`
+
 ## 1.3.3
 
 - Updated dependencies: `@radix-ui/react-one-time-password-field@0.1.3`, `@radix-ui/react-roving-focus@1.1.7`, `@radix-ui/react-menu@2.1.11`, `@radix-ui/react-menubar@1.1.11`, `@radix-ui/react-radio-group@1.3.3`, `@radix-ui/react-tabs@1.1.8`, `@radix-ui/react-toggle-group@1.1.7`, `@radix-ui/react-toolbar@1.1.7`, `@radix-ui/react-context-menu@2.2.11`, `@radix-ui/react-dropdown-menu@2.1.11`
diff --git a/packages/react/radix-ui/package.json b/packages/react/radix-ui/package.json
index d5d69cd2..43167a2a 100644
--- a/packages/react/radix-ui/package.json
+++ b/packages/react/radix-ui/package.json
@@ -1,6 +1,6 @@
 {
   "name": "radix-ui",
-  "version": "1.3.3",
+  "version": "1.3.4",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
@@ -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/packages/react/scroll-area/CHANGELOG.md b/packages/react/scroll-area/CHANGELOG.md
index d1da44a9..96d48014 100644
--- a/packages/react/scroll-area/CHANGELOG.md
+++ b/packages/react/scroll-area/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-scroll-area
 
+## 1.2.6
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.2.5
 
 - Updated dependencies: `@radix-ui/react-primitive@2.1.0`
diff --git a/packages/react/scroll-area/package.json b/packages/react/scroll-area/package.json
index 29ef6ad6..fed057ed 100644
--- a/packages/react/scroll-area/package.json
+++ b/packages/react/scroll-area/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-scroll-area",
-  "version": "1.2.5",
+  "version": "1.2.6",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/slot/src/slot.tsx b/packages/react/slot/src/slot.tsx
index 7e31d665..33fba7ba 100644
--- a/packages/react/slot/src/slot.tsx
+++ b/packages/react/slot/src/slot.tsx
@@ -1,5 +1,5 @@
 import * as React from 'react';
-import { composeRefs } from '@radix-ui/react-compose-refs';
+import { useComposedRefs } from '@radix-ui/react-compose-refs';
 
 /* -------------------------------------------------------------------------------------------------
  * Slot
@@ -66,13 +66,14 @@ interface SlotCloneProps {
 /* @__NO_SIDE_EFFECTS__ */ function createSlotClone(ownerName: string) {
   const SlotClone = React.forwardRef<any, SlotCloneProps>((props, forwardedRef) => {
     const { children, ...slotProps } = props;
+    const childrenRef = React.isValidElement(children) ? getElementRef(children) : undefined;
+    const ref = useComposedRefs(childrenRef, forwardedRef);
 
     if (React.isValidElement(children)) {
-      const childrenRef = getElementRef(children);
       const props = mergeProps(slotProps, children.props as AnyProps);
       // do not pass ref to React.Fragment for React 19 compatibility
       if (children.type !== React.Fragment) {
-        props.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef;
+        props.ref = ref;
       }
       return React.cloneElement(children, props);
     }
diff --git a/packages/react/tabs/CHANGELOG.md b/packages/react/tabs/CHANGELOG.md
index ceebde1f..a6ad9646 100644
--- a/packages/react/tabs/CHANGELOG.md
+++ b/packages/react/tabs/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-tabs
 
+## 1.1.9
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.1.8
 
 - Updated dependencies: `@radix-ui/react-roving-focus@1.1.7`
diff --git a/packages/react/tabs/package.json b/packages/react/tabs/package.json
index 1652190f..d92ad165 100644
--- a/packages/react/tabs/package.json
+++ b/packages/react/tabs/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-tabs",
-  "version": "1.1.8",
+  "version": "1.1.9",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/toast/CHANGELOG.md b/packages/react/toast/CHANGELOG.md
index 0e76b7c0..e5324612 100644
--- a/packages/react/toast/CHANGELOG.md
+++ b/packages/react/toast/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-toast
 
+## 1.2.11
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.2.10
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`
diff --git a/packages/react/toast/package.json b/packages/react/toast/package.json
index eea5db97..94b537c9 100644
--- a/packages/react/toast/package.json
+++ b/packages/react/toast/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-toast",
-  "version": "1.2.10",
+  "version": "1.2.11",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/tooltip/CHANGELOG.md b/packages/react/tooltip/CHANGELOG.md
index b5787d15..1642dd05 100644
--- a/packages/react/tooltip/CHANGELOG.md
+++ b/packages/react/tooltip/CHANGELOG.md
@@ -1,5 +1,9 @@
 # @radix-ui/react-tooltip
 
+## 1.2.4
+
+- Updated dependencies: `@radix-ui/react-presence@1.1.4`
+
 ## 1.2.3
 
 - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.2`
diff --git a/packages/react/tooltip/package.json b/packages/react/tooltip/package.json
index 6d5ec436..d8749d36 100644
--- a/packages/react/tooltip/package.json
+++ b/packages/react/tooltip/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-tooltip",
-  "version": "1.2.3",
+  "version": "1.2.4",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
diff --git a/packages/react/use-is-hydrated/CHANGELOG.md b/packages/react/use-is-hydrated/CHANGELOG.md
new file mode 100644
index 00000000..8a6af81c
--- /dev/null
+++ b/packages/react/use-is-hydrated/CHANGELOG.md
@@ -0,0 +1,5 @@
+# @radix-ui/react-use-is-hydrated
+
+## 0.1.0
+
+- Update the dependency for `use-sync-external-store` to ensure entrypoint is valid
diff --git a/packages/react/use-is-hydrated/package.json b/packages/react/use-is-hydrated/package.json
index 84415162..ec913388 100644
--- a/packages/react/use-is-hydrated/package.json
+++ b/packages/react/use-is-hydrated/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@radix-ui/react-use-is-hydrated",
-  "version": "0.0.0",
+  "version": "0.1.0",
   "license": "MIT",
   "source": "./src/index.ts",
   "main": "./src/index.ts",
@@ -35,7 +35,7 @@
     "build": "radix-build"
   },
   "dependencies": {
-    "use-sync-external-store": "^1.4.0"
+    "use-sync-external-store": "^1.5.0"
   },
   "peerDependencies": {
     "@types/react": "*",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 90fddf88..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
@@ -3059,7 +3120,7 @@ importers:
   packages/react/use-is-hydrated:
     dependencies:
       use-sync-external-store:
-        specifier: ^1.4.0
+        specifier: ^1.5.0
         version: 1.5.0(react@19.1.0)
     devDependencies:
       '@repo/builder':


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/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>
+  );
+}
diff --git a/packages/react/slot/src/slot.test.tsx b/packages/react/slot/src/slot.test.tsx
index a6b3c909..2d2c39ea 100644
--- a/packages/react/slot/src/slot.test.tsx
+++ b/packages/react/slot/src/slot.test.tsx
@@ -1,5 +1,6 @@
-import React from 'react';
+import React, { useState } from 'react';
 import { cleanup, render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { Slot, Slottable } from './slot';
 import { afterEach, describe, it, beforeEach, vi, expect } from 'vitest';
 
@@ -139,6 +140,36 @@ describe('given a Button with Slottable', () => {
   });
 });
 
+describe('given an Input', () => {
+  const handleRef = vi.fn();
+
+  beforeEach(() => {
+    handleRef.mockReset();
+  });
+
+  afterEach(cleanup);
+
+  describe('without asChild', () => {
+    it('should only call function refs once', async () => {
+      render(<Input ref={handleRef} />);
+      await userEvent.type(screen.getByRole('textbox'), 'foo');
+      expect(handleRef).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('with asChild', () => {
+    it('should only call function refs once', async () => {
+      render(
+        <Input asChild ref={handleRef}>
+          <input />
+        </Input>
+      );
+      await userEvent.type(screen.getByRole('textbox'), 'foo');
+      expect(handleRef).toHaveBeenCalledTimes(1);
+    });
+  });
+});
+
 type TriggerProps = React.ComponentProps<'button'> & { as: React.ElementType };
 
 const Trigger = ({ as: Comp = 'button', ...props }: TriggerProps) => <Comp {...props} />;
@@ -160,3 +191,24 @@ const Button = React.forwardRef<
     </Comp>
   );
 });
+
+const Input = React.forwardRef<
+  React.ElementRef<'input'>,
+  React.ComponentProps<'input'> & {
+    asChild?: boolean;
+  }
+>(({ asChild, children, ...props }, forwardedRef) => {
+  const Comp = asChild ? Slot : 'input';
+  const [value, setValue] = useState('');
+
+  return (
+    <Comp
+      {...props}
+      onChange={(event) => setValue(event.target.value)}
+      ref={forwardedRef}
+      value={value}
+    >
+      {children}
+    </Comp>
+  );
+});