Skip to content

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 title
  • description — Optional subtitle
  • children — 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. If nextSubStepId is omitted, resolves from the flow config’s next field.
  • 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>
);
}

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:

  1. All cards after the clicked card are removed from the stack
  2. State reverts to the snapshot from before the target card
  3. 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 message
  • actionLabel — Primary action button label
  • onAction — Primary action handler
  • secondaryLabel — Optional secondary button label
  • onSecondary — 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
flowtrueFlowConfig
iconfalseReactNode
titlefalsestring
stepperTitlefalsestring
contentTitlefalsestring
contentSubtitlefalsestring
drawersfalseRecord<string, ComponentType<DrawerComponentProps>>
onCompletefalse(state: Record<string, unknown>) => void
onClosefalse() => void
leftPanefalseReactNode
panelSizesfalse{ default: 30, min: 20, max: 40 }{ default?: number; min?: number; max?: number }
reactivationPromptfalse{ 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
titletruestring
descriptionfalsestring
childrentrueReactNode
classNamefalsestring

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
varianttrue'info' | 'warning' | 'danger' | 'success'
messagetruestring
actionLabelfalsestring
onActionfalse() => void
secondaryLabelfalsestring
onSecondaryfalse() => 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
statetrueTState (generic type)
statustrue'active' | 'completed' | 'error' | 'skipped'
completetrue(statePatch?: Partial<TState>, nextSubStepId?: string) => void
errortrue() => void
skiptrue(nextSubStepId?: string) => void
openDrawertrue(drawerId: string, props?: Record<string, unknown>) => Promise<DrawerResult>

Best Practices

  1. Type Your State — Use TypeScript generics with useFlowCard<StateType>() to get autocomplete for your accumulated state.

  2. One Concern Per Card — Each card should represent a single logical step. If a card is doing too much, split it into multiple cards.

  3. Use next for Linear Flows — Define next in your flow config for the happy path. Only pass explicit nextSubStepId to complete() or skip() for conditional branching.

  4. Handle Drawer Cancellation — Always check result.success after awaiting openDrawer(). Users can close drawers without completing them.

  5. Minimize Drawer Content — Drawers are best for focused tasks (authentication, configuration). For complex workflows, use additional substeps instead.

  6. 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-label on step buttons, and aria-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-motion for 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