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 substeps, and back-navigation with reactivation guards.
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.
Substep States
The left-pane timeline renders distinct substep states — completed (green check), skipped (arrow), and errored (red). A step whose failed substep is retried and completed shows as completed overall, with the failed substep 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 substeps 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 = { steps: { select: { title: "Select Provider", description: "Choose VCS" }, configure: { title: "Configure", description: "Set up connection" }, done: { title: "Done", description: "All set" }, }, subSteps: { "provider-selection": { step: "select", component: ProviderSelectionCard, next: "connector-setup" }, "connector-setup": { step: "configure", component: ConnectorSetupCard, next: "success" }, "success": { step: "done", component: SuccessCard }, }, initialSubStep: "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.subSteps */}</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:
interface FlowConfig { steps: Record<string, StepConfig>; // Stepper steps (left pane) subSteps: Record<string, SubStepConfig>; // Card definitions (right pane) initialSubStep: string; // First card to show}
interface StepConfig { title: string; description?: string;}
interface SubStepConfig { step: string; // Which step this substep 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 substep ID (for routing)}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>, nextSubStepId?: string) => void; error: () => void; skip: (nextSubStepId?: string) => void; openDrawer: ( drawerId: string, props?: Record<string, unknown>, ) => Promise<DrawerResult>;}Methods:
complete(statePatch?, nextSubStepId?)— Mark the card as completed, merge state, and advance. IfnextSubStepIdis omitted, resolves from the flow config’snextfield.error()— Mark the card as errored (shows error styling, enables retry)skip(nextSubStepId?)— 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-substep-id"); };}Substeps and Predicted Path
Substeps are defined declaratively in the flow config. The stepper shows visited substeps and a “predicted path” — the linear chain of upcoming substeps reachable via next pointers within the active step:
const flow: FlowConfig = { steps: { analysis: { title: "Code Analysis", description: "Scan and analyze" }, }, subSteps: { 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 }, }, initialSubStep: "detect",};When detect is active, the stepper shows deps and tests as predicted upcoming substeps. As each card completes, it advances through the chain automatically.
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 (header hidden if no icon or title) 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 panelSizes={{ default: 30, min: 20, max: 40 }} // [OPTIONAL] Left panel sizing (%) reactivationPrompt={{ title: "Go back?", description: "Progress will be lost." }} // [OPTIONAL]/>Prop | Required | Default | Type |
|---|---|---|---|
| flow | true | FlowConfig | |
| icon | false | ReactNode | |
| title | false | string | |
| 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 | |
| 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 } |
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>, nextSubStepId?: string) => void | |
| error | true | () => void | |
| skip | true | (nextSubStepId?: 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 explicitnextSubStepIdtocomplete()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 substeps 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