diff --git a/.changeset/quiet-pans-smile.md b/.changeset/quiet-pans-smile.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/quiet-pans-smile.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/headless/package.json b/packages/headless/package.json
index fb38084db5e..ea14c88fa67 100644
--- a/packages/headless/package.json
+++ b/packages/headless/package.json
@@ -13,6 +13,10 @@
"import": "./dist/primitives/button/index.js",
"types": "./dist/primitives/button/index.d.ts"
},
+ "./input": {
+ "import": "./dist/primitives/input/index.js",
+ "types": "./dist/primitives/input/index.d.ts"
+ },
"./tabs": {
"import": "./dist/primitives/tabs/index.js",
"types": "./dist/primitives/tabs/index.d.ts"
diff --git a/packages/headless/src/primitives/input/README.md b/packages/headless/src/primitives/input/README.md
new file mode 100644
index 00000000000..eeb0cfc547a
--- /dev/null
+++ b/packages/headless/src/primitives/input/README.md
@@ -0,0 +1,12 @@
+# Input
+
+An unstyled native input primitive for standalone fields and compound controls. It supports the shared `render` escape hatch and reflects native state through `data-disabled`, `data-invalid`, and `data-readonly` attributes for styling.
+
+```tsx
+import { Input } from '@clerk/headless/input';
+
+;
+```
diff --git a/packages/headless/src/primitives/input/index.ts b/packages/headless/src/primitives/input/index.ts
new file mode 100644
index 00000000000..f47a3e09061
--- /dev/null
+++ b/packages/headless/src/primitives/input/index.ts
@@ -0,0 +1 @@
+export { Input, type InputProps } from './input';
diff --git a/packages/headless/src/primitives/input/input.test.tsx b/packages/headless/src/primitives/input/input.test.tsx
new file mode 100644
index 00000000000..56591e2b4f8
--- /dev/null
+++ b/packages/headless/src/primitives/input/input.test.tsx
@@ -0,0 +1,54 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import React from 'react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { Input } from './input';
+
+afterEach(() => cleanup());
+
+describe('Input', () => {
+ it('renders a native input and forwards its props and ref', () => {
+ const ref = React.createRef();
+ render(
+ ,
+ );
+
+ const input = screen.getByRole('textbox', { name: 'Name' });
+ expect(input.tagName).toBe('INPUT');
+ expect(input).toHaveAttribute('name', 'name');
+ expect(ref.current).toBe(input);
+ });
+
+ it('reflects native state for styling', () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole('textbox', { name: 'Name' });
+ expect(input).toHaveAttribute('data-disabled', '');
+ expect(input).toHaveAttribute('data-invalid', '');
+ expect(input).toHaveAttribute('data-readonly', '');
+ });
+
+ it('supports the headless render escape hatch', () => {
+ render(
+ }
+ data-input='grouped'
+ />,
+ );
+
+ const input = screen.getByRole('textbox', { name: 'Biography' });
+ expect(input.tagName).toBe('TEXTAREA');
+ expect(input).toHaveAttribute('data-input', 'grouped');
+ });
+});
diff --git a/packages/headless/src/primitives/input/input.tsx b/packages/headless/src/primitives/input/input.tsx
new file mode 100644
index 00000000000..753e104125e
--- /dev/null
+++ b/packages/headless/src/primitives/input/input.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import React from 'react';
+
+import { type ComponentProps, useRender } from '../../utils';
+
+/** Props for the unstyled input primitive. */
+export type InputProps = ComponentProps<'input'>;
+
+/**
+ * An unstyled native input with render-prop support and reflected state attributes.
+ * Styled layers can use it for standalone fields or place it inside compound controls.
+ */
+export const Input = React.forwardRef(function Input(
+ { render, disabled = false, readOnly = false, 'aria-invalid': ariaInvalid, ...otherProps },
+ ref,
+) {
+ const invalid = ariaInvalid === true || ariaInvalid === 'true';
+
+ return useRender({
+ defaultTagName: 'input',
+ render,
+ ref,
+ state: { disabled, invalid, readOnly },
+ stateAttributesMapping: {
+ disabled: value => (value ? { 'data-disabled': '' } : null),
+ invalid: value => (value ? { 'data-invalid': '' } : null),
+ readOnly: value => (value ? { 'data-readonly': '' } : null),
+ },
+ props: {
+ disabled,
+ readOnly,
+ 'aria-invalid': ariaInvalid,
+ ...otherProps,
+ },
+ });
+});
diff --git a/packages/headless/vite.config.ts b/packages/headless/vite.config.ts
index 0722ae12526..e71a08ab0ff 100644
--- a/packages/headless/vite.config.ts
+++ b/packages/headless/vite.config.ts
@@ -14,6 +14,7 @@ export default defineConfig({
entry: {
'primitives/accordion/index': 'src/primitives/accordion/index.ts',
'primitives/button/index': 'src/primitives/button/index.ts',
+ 'primitives/input/index': 'src/primitives/input/index.ts',
'primitives/tabs/index': 'src/primitives/tabs/index.ts',
'primitives/tooltip/index': 'src/primitives/tooltip/index.ts',
'primitives/popover/index': 'src/primitives/popover/index.ts',
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index afe67254bce..4ecf861a2a1 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -71,6 +71,7 @@ const docModules: Record> = {
drawer: dynamic(() => import('../stories/drawer.mdx')),
'file-upload': dynamic(() => import('../stories/file-upload.mdx')),
flow: dynamic(() => import('../stories/flow.mdx')),
+ input: dynamic(() => import('../stories/input.primitive.mdx')),
menu: dynamic(() => import('../stories/menu.mdx')),
otp: dynamic(() => import('../stories/otp.mdx')),
popover: dynamic(() => import('../stories/popover.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 8f326162f60..42d2efb43ad 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -62,6 +62,7 @@ import {
Sizes as IconFrameSizes,
Treatments as IconFrameTreatments,
} from '../stories/icon-frame.stories';
+import { meta as inputPrimitiveMeta } from '../stories/input.primitive.stories';
import {
Default,
Disabled as InputDisabled,
@@ -348,6 +349,7 @@ const dialogModule: StoryModule = { meta: dialogMeta };
const drawerModule: StoryModule = { meta: drawerMeta };
const fileUploadModule: StoryModule = { meta: fileUploadMeta };
const flowModule: StoryModule = { meta: flowMeta };
+const inputPrimitiveModule: StoryModule = { meta: inputPrimitiveMeta };
const menuModule: StoryModule = { meta: menuMeta };
const otpModule: StoryModule = { meta: otpMeta };
const popoverModule: StoryModule = { meta: popoverMeta };
@@ -520,6 +522,7 @@ export const registry: StoryModule[] = [
drawerModule,
fileUploadModule,
flowModule,
+ inputPrimitiveModule,
menuModule,
otpModule,
popoverModule,
diff --git a/packages/swingset/src/stories/input.primitive.mdx b/packages/swingset/src/stories/input.primitive.mdx
new file mode 100644
index 00000000000..02e16757595
--- /dev/null
+++ b/packages/swingset/src/stories/input.primitive.mdx
@@ -0,0 +1,74 @@
+import * as InputStories from './input.primitive.stories';
+
+# Input
+
+An unstyled native input from `@clerk/headless`. It provides the shared `render` escape hatch and reflects native disabled, invalid, and read-only state as `data-*` attributes, but ships no styles.
+
+## Example
+
+The demo renders the raw primitive with only the browser's native appearance.
+
+
+
+## Usage
+
+```tsx
+import { Input } from '@clerk/headless/input';
+
+;
+```
+
+Use `render` when a styled layer or compound control needs to supply another input-like element:
+
+```tsx
+}
+ aria-label='Biography'
+/>
+```
+
+## Parts
+
+| Part | Default Element | Description |
+| ------- | --------------- | ---------------------------------------------------------------- |
+| `Input` | `` | Native input with polymorphic rendering and reflected state data |
+
+`Input` accepts the shared `render` prop and all standard attributes for its default element.
+
+## Props
+
+| Prop | Type | Default | Description |
+| -------------- | -------------------------------- | ----------- | -------------------------------------------------------- |
+| `render` | `ReactElement \| RenderFunction` | `` | Replaces the default element while merging props and ref |
+| `disabled` | `boolean` | `false` | Disables the native control |
+| `readOnly` | `boolean` | `false` | Makes the native control read-only |
+| `aria-invalid` | `boolean \| 'true' \| 'false'` | `undefined` | Exposes the control's validation state |
+
+All other native input attributes pass through unchanged.
+
+## Styling
+
+The primitive exposes state through attributes that any styling system can target:
+
+| Attribute | Description |
+| --------------- | ------------------------------------- |
+| `data-disabled` | Present when `disabled` is `true` |
+| `data-invalid` | Present when `aria-invalid` is `true` |
+| `data-readonly` | Present when `readOnly` is `true` |
+
+```css
+input[data-invalid] {
+ border-color: red;
+}
+```
diff --git a/packages/swingset/src/stories/input.primitive.stories.tsx b/packages/swingset/src/stories/input.primitive.stories.tsx
new file mode 100644
index 00000000000..453daffeec5
--- /dev/null
+++ b/packages/swingset/src/stories/input.primitive.stories.tsx
@@ -0,0 +1,24 @@
+import { Input } from '@clerk/headless/input';
+
+import type { StoryMeta } from '@/lib/types';
+
+export const meta: StoryMeta = {
+ group: 'Primitives',
+ title: 'Input',
+ source: 'packages/headless/src/primitives/input/index.ts',
+};
+
+export function Default() {
+ return (
+
+
+
+
+ );
+}