diff --git a/apps/studio/components/ui/SteppedFlow/SteppedFlow.test.tsx b/apps/studio/components/ui/SteppedFlow/SteppedFlow.test.tsx
new file mode 100644
index 0000000000000..c0c65150a3d0c
--- /dev/null
+++ b/apps/studio/components/ui/SteppedFlow/SteppedFlow.test.tsx
@@ -0,0 +1,173 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { describe, expect, test, vi } from 'vitest'
+
+import { SteppedFlow, SteppedFlowHeader } from './SteppedFlow'
+import { customRender } from '@/tests/lib/custom-render'
+
+const steps = [
+ { id: 'destination', label: 'Destination' },
+ { id: 'connection', label: 'Connection' },
+ { id: 'review', label: 'Review' },
+]
+
+describe('SteppedFlow', () => {
+ test('does not show Back on the first step', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
+ })
+
+ test('shows Cancel on the first step when onCancel is provided', () => {
+ const onCancel = vi.fn()
+
+ customRender(
+
+ Step body
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(onCancel).toHaveBeenCalledOnce()
+ })
+
+ test('does not show Cancel after the first step', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
+ })
+
+ test('shows Back after the first step', () => {
+ const onStepChange = vi.fn()
+
+ customRender(
+
+ Step body
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Back' }))
+ expect(onStepChange).toHaveBeenCalledWith('destination')
+ })
+
+ test('advances to the next step when onNext is omitted', () => {
+ const onStepChange = vi.fn()
+
+ customRender(
+
+ Step body
+
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+ expect(onStepChange).toHaveBeenCalledWith('connection')
+ })
+
+ test('shows the final action on the last step instead of Next', () => {
+ const onFinal = vi.fn()
+
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.queryByRole('button', { name: /Next/ })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Create and start pipeline' }))
+ expect(onFinal).toHaveBeenCalledOnce()
+ })
+
+ test('renders a step header heading', () => {
+ customRender(
+
+
+
+ )
+
+ expect(screen.getByRole('heading', { name: 'Choose a destination' })).toBeInTheDocument()
+ expect(screen.getByText('Where should data go?')).toBeInTheDocument()
+ })
+
+ test('renders optional header actions', () => {
+ customRender(
+
+
+ Docs
+
+ }
+ />
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Docs' })).toBeInTheDocument()
+ })
+
+ test('disables Back while navigation is locked', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled()
+ })
+
+ test('disables Next while navigation is locked', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
+ })
+
+ test('disables the final action while navigation is locked', () => {
+ customRender(
+
+ Step body
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Create and start pipeline' })).toBeDisabled()
+ })
+})
diff --git a/apps/studio/components/ui/SteppedFlow/SteppedFlow.tsx b/apps/studio/components/ui/SteppedFlow/SteppedFlow.tsx
new file mode 100644
index 0000000000000..0d149df260cee
--- /dev/null
+++ b/apps/studio/components/ui/SteppedFlow/SteppedFlow.tsx
@@ -0,0 +1,164 @@
+import { type ReactNode } from 'react'
+import { Button, Card, CardFooter, CardHeader, cn } from 'ui'
+
+export type SteppedFlowStep = {
+ id: string
+ label: string
+}
+
+export type SteppedFlowFinalAction = {
+ label: string
+ onClick?: () => void
+ loading?: boolean
+ disabled?: boolean
+ form?: string
+ type?: 'button' | 'submit'
+}
+
+export const SteppedFlowHeader = ({
+ title,
+ description,
+ actions,
+ children,
+}: {
+ title: string
+ description?: ReactNode
+ actions?: ReactNode
+ children?: ReactNode
+}) => {
+ return (
+
+
+
+
+
{title}
+ {description ?
{description}
: null}
+
+ {actions ?
{actions}
: null}
+
+ {children}
+
+
+ )
+}
+
+export interface SteppedFlowProps {
+ steps: SteppedFlowStep[]
+ currentStep: string
+ onStepChange: (stepId: string) => void
+ nextDisabled?: boolean
+ nextLabel?: string
+ onNext?: () => void
+ nextLoading?: boolean
+ navigationDisabled?: boolean
+ onCancel?: () => void
+ cancelLabel?: string
+ finalAction?: SteppedFlowFinalAction
+ children: ReactNode
+}
+
+export const SteppedFlow = ({
+ steps,
+ currentStep,
+ onStepChange,
+ nextDisabled = false,
+ nextLabel = 'Next',
+ onNext,
+ nextLoading = false,
+ navigationDisabled = false,
+ onCancel,
+ cancelLabel = 'Cancel',
+ finalAction,
+ children,
+}: SteppedFlowProps) => {
+ const currentIndex = Math.max(
+ 0,
+ steps.findIndex((step) => step.id === currentStep)
+ )
+ const stepCount = steps.length
+ const isLastStep = stepCount > 0 && currentIndex === stepCount - 1
+ const isFirstStep = currentIndex === 0
+ const currentStepLabel = steps[currentIndex]?.label
+ const showCancel = isFirstStep && !!onCancel
+ const nextStepId = steps[currentIndex + 1]?.id
+
+ const handleNext = () => {
+ if (onNext) {
+ onNext()
+ return
+ }
+
+ if (nextStepId) {
+ onStepChange(nextStepId)
+ }
+ }
+
+ if (stepCount === 0) {
+ return null
+ }
+
+ return (
+
+
+
+ Step {currentIndex + 1} of {stepCount}
+ {currentStepLabel ? ` ยท ${currentStepLabel}` : ''}
+
+
+ {children}
+ 0 || showCancel ? 'justify-between' : 'justify-end')}
+ >
+ {currentIndex > 0 ? (
+
+ ) : null}
+ {currentIndex === 0 && showCancel ? (
+
+ ) : null}
+
+ {isLastStep && finalAction ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ )
+}