diff --git a/.changeset/quiet-flows-move.md b/.changeset/quiet-flows-move.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/quiet-flows-move.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/headless/package.json b/packages/headless/package.json
index a3145b4e55c..31369df6092 100644
--- a/packages/headless/package.json
+++ b/packages/headless/package.json
@@ -53,6 +53,10 @@
"import": "./dist/primitives/file-upload/index.js",
"types": "./dist/primitives/file-upload/index.d.ts"
},
+ "./flow": {
+ "import": "./dist/primitives/flow/index.js",
+ "types": "./dist/primitives/flow/index.d.ts"
+ },
"./otp": {
"import": "./dist/primitives/otp/index.js",
"types": "./dist/primitives/otp/index.d.ts"
diff --git a/packages/headless/src/primitives/flow/README.md b/packages/headless/src/primitives/flow/README.md
new file mode 100644
index 00000000000..d445061ba94
--- /dev/null
+++ b/packages/headless/src/primitives/flow/README.md
@@ -0,0 +1,71 @@
+# Flow
+
+A controlled, headless primitive for rendering one step of a multi-step flow at a time while preserving outgoing steps until their exit animations finish.
+
+## Usage
+
+```tsx
+import { Flow } from '@clerk/headless/flow';
+
+
value | string | — (required) | Active controller state |
+| direction | -1 \| 1 | 1 | Direction used by the step transition styles |
+
+### `Flow.Step`
+
+| Prop | Type | Default | Description |
+| ---------------- | ------------------------------ | ------------ | ------------------------------------------ |
+| ids | readonly string[] | — (required) | Controller states represented by this step |
+
+## Animation lifecycle
+
+`Flow.Step` emits the same transition lifecycle attributes used by the other headless primitives:
+
+| Attribute | Description |
+| --------------------- | ------------------------------------------------------ |
+| `data-open` | The step is active |
+| `data-closed` | The step is exiting |
+| `data-starting-style` | Present on the incoming step's initial animation frame |
+| `data-ending-style` | Present while the outgoing animation is finishing |
+
+The initially active step never receives `data-starting-style`. Exiting steps are inert, hidden from the accessibility tree, and retain their last active children until their exit animation completes.
+
+Direction is available to each step as `--cl-flow-transition-direction`, whose value is exactly `1` or `-1`:
+
+```css
+.flow-step[data-starting-style] {
+ opacity: 0;
+ transform: translateX(calc(var(--cl-flow-transition-direction) * 1.5rem));
+}
+
+.flow-step[data-ending-style] {
+ opacity: 0;
+ transform: translateX(calc(var(--cl-flow-transition-direction) * -1.5rem));
+}
+```
+
+## Viewport height
+
+`Flow.Root` measures the active or entering step and publishes its height as `--cl-flow-step-height`. It carries `data-initial` through the first measured frame so the first step and initial viewport height can render without motion.
+
+```css
+.flow-root {
+ height: var(--cl-flow-step-height, auto);
+ overflow: hidden;
+ position: relative;
+ transition: height 240ms ease;
+}
+
+.flow-root[data-initial] {
+ transition: none;
+}
+
+.flow-step[data-closed] {
+ inset: 0;
+ position: absolute;
+}
+```
+
+The outgoing step becomes absolute so the entering step determines layout and therefore the root's target height. Keep reduced-motion handling in the styled layer by disabling these transitions under `prefers-reduced-motion`.
diff --git a/packages/swingset/src/stories/flow.stories.tsx b/packages/swingset/src/stories/flow.stories.tsx
new file mode 100644
index 00000000000..4d0226a6f69
--- /dev/null
+++ b/packages/swingset/src/stories/flow.stories.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { Flow, type FlowDirection } from '@clerk/headless/flow';
+import { useState } from 'react';
+
+import type { StoryMeta } from '@/lib/types';
+
+export const meta: StoryMeta = {
+ group: 'Primitives',
+ title: 'Flow',
+ source: 'packages/headless/src/primitives/flow/index.ts',
+};
+
+const steps = [
+ {
+ id: 'account',
+ title: 'Account details',
+ description: 'Confirm the account that this action applies to.',
+ },
+ {
+ id: 'verification',
+ title: 'Verify your identity',
+ description:
+ 'Enter the verification code sent to your primary email address. The additional copy makes this step taller so the viewport height transition is visible.',
+ },
+ {
+ id: 'complete',
+ title: 'Complete',
+ description: 'Your identity has been verified.',
+ },
+] as const;
+
+export function Default() {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [direction, setDirection] = useState{step.description}
+