Dual Pane Stepper
The DualPaneStepper is a full-page builder shell component for multi-step guided flows. It combines a left-pane stepper with a right-pane card stack that supports decision-tree navigation, drawer overlays, dynamic steps, and back-navigation with reactivation guards.
It shares the same FlowConfig, useFlowCard, and Root engine APIs as SinglePaneStepper. The only intentional difference is layout: DualPane renders cards in a right pane, SinglePane embeds them in the timeline. DualPane-only extras are leftPane and panelSizes.
Use this component for onboarding wizards, setup flows, and any multi-step process where the user’s choices determine the next card(s) to show.
Step States
The left-pane timeline renders distinct step states — completed (green check), skipped (arrow), and errored (red). A step group whose failed step is retried and completed shows as completed overall, with the failed step still marked in the timeline. This example settles into a fully resolved flow on load.
Interactive Example
This example demonstrates a full CI pipeline onboarding flow with connector drawers, connectivity checks, delegate installation detours, dynamic steps during code analysis, and pipeline YAML generation.
Usage
import { DualPaneStepper, useFlowCard } from "@harnessio/ui/components";import type { FlowConfig } from "@harnessio/ui/components";
const flow: FlowConfig = { stepGroups: { select: { title: "Select Provider", description: "Choose VCS" }, configure: { title: "Configure", description: "Set up connection" }, done: { title: "Done", description: "All set" }, }, steps: { "provider-selection": { step: "select", component: ProviderSelectionCard, next: "connector-setup" }, "connector-setup": { step: "configure", component: ConnectorSetupCard, next: "success" }, "success": { step: "done", component: SuccessCard }, }, initialStep: "provider-selection",};
<DualPaneStepper.Root flow={flow} icon={<Logo />} title="Create a pipeline" drawers={{ connector: ConnectorDrawer }} onComplete={(state) => console.log("Done!", state)}/>Anatomy
The Dual Pane Stepper uses a compound component pattern:
<DualPaneStepper.Root flow={flowConfig} drawers={{ connector: ConnectorDrawer }}> {/* Cards are rendered automatically from flowConfig.steps */}</DualPaneStepper.Root>Cards use the useFlowCard hook to interact with the flow engine:
function MyCard() { const { state, status, complete, error, skip, openDrawer } = useFlowCard();
return ( <DualPaneStepper.Card title="My Card"> {/* Card content */} </DualPaneStepper.Card> );}Flow Configuration
The FlowConfig object defines the structure of your flow:
type FlowConfig = GroupedFlowConfig | FlatFlowConfig;
interface GroupedFlowConfig { stepGroups: Record<string, StepGroupConfig>; // Stepper step groups (left pane) steps: Record<string, GroupedStepConfig>; // Card definitions (right pane), one group per step initialStep: string; // First card to show}
interface FlatFlowConfig { // Omit stepGroups entirely for a flat left pane — no top-level grouping, each step // renders directly under the stepper root. steps: Record<string, FlatStepConfig>; initialStep: string;}
interface StepGroupConfig { title: string; description?: string;}
interface GroupedStepConfig { step: string; // Which step group this step belongs to title: string; // Shown in the stepper description?: string; // Shown in the stepper component: ComponentType; // React component to render as a card next?: string; // Next step ID (for routing) terminal?: boolean; // Auto-complete on entry (for summary cards) visualCompleted?: boolean; // Always render step as finished, regardless of actual status — presentation-only hint}
interface FlatStepConfig { // No `step` field — a flat step belongs to no group. title: string; description?: string; component: ComponentType; next?: string; terminal?: boolean; visualCompleted?: boolean; // Always render step as finished, regardless of actual status — presentation-only hint}Cards
Use DualPaneStepper.Card inside your card components. The card shows a status indicator (active dot, check, error, skip), title, optional description, and content. Completed cards show a restart button on hover.
function SelectProviderCard() { const { complete } = useFlowCard(); const [selected, setSelected] = useState(null);
return ( <DualPaneStepper.Card title="Select Provider" description="Choose your version control provider" > <button onClick={() => { setSelected('GitHub'); complete({ provider: 'github' }); }}> GitHub </button> </DualPaneStepper.Card> );}Props:
title— Card titledescription— Optional subtitlechildren— Card content
useFlowCard Hook
The useFlowCard hook provides access to the flow engine from within a card component:
interface FlowCardContext<TState = Record<string, unknown>> { state: TState; // Accumulated state from previous cards status: CardStatus; // 'active' | 'completed' | 'error' | 'skipped' complete: (statePatch?: Partial<TState>, nextStepId?: string) => void; error: () => void; skip: (nextStepId?: string) => void; openDrawer: ( drawerId: string, props?: Record<string, unknown>, ) => Promise<DrawerResult>;}Methods:
complete(statePatch?, nextStepId?)— Mark the card as completed, merge state, and advance. IfnextStepIdis omitted, resolves from the flow config’snextfield.error()— Mark the card as errored (shows error styling, enables retry)skip(nextStepId?)— Mark the card as skipped and advance. Resolves destination from flow config if omitted. Use an explicit destination for conditional branching.openDrawer(drawerId, props?)— Open a registered drawer and await the result
State Management
Each card can read the accumulated state and add to it:
function MyCard() { const { state, complete } = useFlowCard<{ provider?: string; repo?: string; }>();
// Access previous state console.log(state.provider); // 'github' (from a previous card)
// Add to state when navigating forward (resolves next from flow config) const handleNext = () => { complete({ repo: "my-repo" }); // Next card will see { provider: 'github', repo: 'my-repo' } };
// Or specify an explicit destination for conditional branching const handleBranch = () => { complete({ repo: "my-repo" }, "specific-step-id"); };}Steps and Predicted Path
Steps are defined declaratively in the flow config. The stepper shows visited steps and a “predicted path” — the linear chain of upcoming steps reachable via next pointers within the active step group:
const flow: FlowConfig = { stepGroups: { analysis: { title: "Code Analysis", description: "Scan and analyze" }, }, steps: { detect: { step: "analysis", title: "Detect Languages", component: DetectCard, next: "deps", }, deps: { step: "analysis", title: "Scan Dependencies", component: DepsCard, next: "tests", }, tests: { step: "analysis", title: "Test Framework", component: TestsCard }, }, initialStep: "detect",};When detect is active, the stepper shows deps and tests as predicted upcoming steps. As each card completes, it advances through the chain automatically.
Flat Timelines
Omit stepGroups entirely and the left pane renders a flat top-level list instead — one Stepper.Step per step, in cardHistory order, with no branch connector or step-group nesting. This is the same flat mode SinglePaneStepper supports; the only difference is which pane the timeline lives in.
const flow: FlatFlowConfig = { steps: { detect: { title: "Detect Languages", component: DetectCard, next: "deps" }, deps: { title: "Scan Dependencies", component: DepsCard, next: "tests" }, tests: { title: "Test Framework", component: TestsCard, next: "review" }, review: { title: "Review & Confirm", component: ReviewCard }, }, initialStep: "detect",};Drawer Integration
Drawers provide modal overlays for complex interactions like authentication flows or configuration forms. Register drawers via the drawers prop on DualPaneStepper.Root:
<DualPaneStepper.Root flow={flow} drawers={{ connector: ConnectorDrawer, delegate: DelegateDrawer, }}/>Open drawers from any card using the openDrawer method:
function ConnectorSetupCard() { const { openDrawer, complete } = useFlowCard();
const handleConnect = async () => { const result = await openDrawer('connector');
if (result.success && result.data) { complete({ connectorRef: result.data.connectorRef }); } };
return ( <DualPaneStepper.Card title="Connect Provider"> <button onClick={handleConnect}>Connect</button> </DualPaneStepper.Card> );}Drawer Component Interface:
interface DrawerComponentProps { open: boolean; onClose: (result: DrawerResult) => void; props?: Record<string, unknown>;}
interface DrawerResult { success: boolean; data?: Record<string, unknown>;}
function ConnectorDrawer({ open, onClose, props }: DrawerComponentProps) { const handleSubmit = () => { onClose({ success: true, data: { connectorRef: 'my-connector' } }); };
if (!open) return null;
return ( <div className="fixed inset-0 z-50 ..."> {/* Your drawer UI */} </div> );}Navigation & Reactivation
Forward Navigation
Call complete(statePatch?) to navigate to the next card (resolved from flow config). The card stack grows, and the stepper advances.
Back Navigation
Users can click completed cards in the stack to go back. When navigating backward:
- All cards after the clicked card are removed from the stack
- State reverts to the snapshot from before the target card
- The reactivation confirmation dialog is shown
Reactivation Guard
The reactivation guard prevents users from accidentally losing progress when navigating backward. It shows a confirmation dialog with a customizable message:
<DualPaneStepper.Root flow={flow} reactivationPrompt={{ title: "Go back?", description: "Going back to this step will discard your progress on subsequent steps." }}/>CardAction Component
Use CardAction to show contextual alerts or prompts within cards:
<DualPaneStepper.Card title="Connectivity Check"> <div className="space-y-4"> {/* Logs or results */}
{error && ( <DualPaneStepper.CardAction variant="warning" message="Cannot reach git provider. Your infrastructure may be behind a firewall." actionLabel="Install Delegate" onAction={() => openDrawer('delegate')} secondaryLabel="Skip for now" onSecondary={() => skip()} /> )} </div></DualPaneStepper.Card>Props:
variant— Visual style:'info' | 'warning' | 'danger' | 'success'message— Alert messageactionLabel— Primary action button labelonAction— Primary action handlersecondaryLabel— Optional secondary button labelonSecondary— Optional secondary action handler
API Reference
Root
The root component that sets up the flow engine, renders the dual-pane layout, and manages navigation.
<DualPaneStepper.Root flow={flowConfig} // [REQUIRED] Flow configuration icon={<Logo />} // [OPTIONAL] Element rendered before the title in header title="Create a pipeline" // [OPTIONAL] Header title showStepperHeader // [OPTIONAL] Show stepperTitle above the timeline (default false) stepperTitle="Setup Steps" // [OPTIONAL] Title shown in stepper pane contentTitle="Configuration" // [OPTIONAL] Heading above card stack contentSubtitle="Follow the steps below" // [OPTIONAL] Subtitle under contentTitle drawers={{ connector: ConnectorDrawer }} // [OPTIONAL] Drawer components keyed by ID onComplete={(state) => console.log(state)} // [OPTIONAL] Called when the final card completes onClose={() => navigate('/')} // [OPTIONAL] Renders close button in header leftPane={<CustomStepper />} // [OPTIONAL] Override entire left pane content (layout) panelSizes={{ default: 30, min: 20, max: 40 }} // [OPTIONAL] Left panel sizing (%) (layout) reactivationPrompt={{ title: "Go back?", description: "Progress will be lost." }} // [OPTIONAL] hideUpcomingGroups={false} // [OPTIONAL] Hide upcoming groups (default stepper pane) hidePredictedSteps={false} // [OPTIONAL] Hide predicted/upcoming step placeholders initialEngineState={snapshot} // [OPTIONAL] Resume a persisted engine snapshot> {/* optional persist-bridge children, inside FlowEngineProvider */}</DualPaneStepper.Root>Prop | Required | Default | Type |
|---|---|---|---|
| flow | true | FlowConfig | |
| icon | false | ReactNode | |
| title | false | string | |
| showStepperHeader | false | false | boolean |
| stepperTitle | false | string | |
| contentTitle | false | string | |
| contentSubtitle | false | string | |
| drawers | false | Record<string, ComponentType<DrawerComponentProps>> | |
| onComplete | false | (state: Record<string, unknown>) => void | |
| onClose | false | () => void | |
| showRootHeader | false | true | boolean |
| hideHeader | false | false | boolean |
| leftPane | false | ReactNode | |
| panelSizes | false | { default: 30, min: 20, max: 40 } | { default?: number; min?: number; max?: number } |
| reactivationPrompt | false | { title: "Go back?", description: "Going back will discard progress..." } | { title: string; description: string } |
| hideUpcomingGroups | false | false | boolean |
| hidePredictedSteps | false | false | boolean |
| disableAutoScroll | false | false | boolean |
| showStepBadge | false | false | boolean |
| initialEngineState | false | InitialEngineState | |
| children | false | ReactNode | |
| className | false | string | |
| style | false | CSSProperties |
Card
A card displayed in the right-pane stack. Shows status indicator (active dot, check, error, skip), title, optional description, and content. Completed cards show a restart button on hover.
<DualPaneStepper.Card title="Card Title" description="Optional subtitle"> {/* Card content */}</DualPaneStepper.Card>Prop | Required | Default | Type |
|---|---|---|---|
| title | true | string | |
| description | false | string | |
| children | true | ReactNode | |
| className | false | string |
CardAction
An alert/action component for showing contextual prompts within cards.
<DualPaneStepper.CardAction variant="warning" message="Alert message" actionLabel="Primary Action" onAction={handlePrimary} secondaryLabel="Secondary Action" onSecondary={handleSecondary}/>Prop | Required | Default | Type |
|---|---|---|---|
| variant | true | 'info' | 'warning' | 'danger' | 'success' | |
| message | true | string | |
| actionLabel | false | string | |
| onAction | false | () => void | |
| secondaryLabel | false | string | |
| onSecondary | false | () => void |
useFlowCard
Hook for accessing the flow engine from within a card component.
const { state, status, complete, error, skip, openDrawer } = useFlowCard<StateType>();Returns:
Prop | Required | Default | Type |
|---|---|---|---|
| state | true | TState (generic type) | |
| status | true | 'active' | 'completed' | 'error' | 'skipped' | |
| complete | true | (statePatch?: Partial<TState>, nextStepId?: string) => void | |
| error | true | () => void | |
| skip | true | (nextStepId?: string) => void | |
| openDrawer | true | (drawerId: string, props?: Record<string, unknown>) => Promise<DrawerResult> |
Best Practices
-
Type Your State — Use TypeScript generics with
useFlowCard<StateType>()to get autocomplete for your accumulated state. -
One Concern Per Card — Each card should represent a single logical step. If a card is doing too much, split it into multiple cards.
-
Use
nextfor Linear Flows — Definenextin your flow config for the happy path. Only pass explicitnextStepIdtocomplete()orskip()for conditional branching. -
Handle Drawer Cancellation — Always check
result.successafter awaitingopenDrawer(). Users can close drawers without completing them. -
Minimize Drawer Content — Drawers are best for focused tasks (authentication, configuration). For complex workflows, use additional steps instead.
-
Test Back Navigation — Verify that your flow works correctly when users navigate backward. Ensure the reactivation guard message is clear and helpful.
Accessibility
The Dual Pane Stepper implements the following accessibility features:
- ARIA: Uses
aria-current="step"on the active step,aria-labelon step buttons, andaria-live="polite"for step change announcements - Keyboard Navigation: Supports ArrowUp/ArrowDown, Home/End on the stepper; standard tab/focus flow on cards
- Reduced Motion: Respects
prefers-reduced-motionfor card transitions and stepper animations - Focus Management: Automatically focuses the next card when navigating forward, and the clicked card when navigating backward
- Semantic HTML: Uses
<nav>for the stepper and proper heading hierarchy in cards