Step By Step

Numbered step guide with optional interactive completion tracking.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/step-by-step.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { useState } from "react"; import type { ReactNode } from "react"; import type { HeadingTag } from "../../lib/types"; import { cn } from "../../lib/utils"; export type StepProps = { children: ReactNode; className?: string; number?: number; title: string; }; function Step({ children, className, number, title, }: StepProps): React.ReactNode { return ( <div className={cn("flex gap-4", className)}> <div className="flex flex-col items-center"> <div className="flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold"> {number} </div> <div className="w-px flex-1 bg-border mt-2" /> </div> <div className="flex-1 pb-8 last:pb-0"> <h4 className="font-semibold text-foreground mb-2">{title}</h4> <div className="text-sm text-muted-foreground [&>p]:mb-2 [&>pre]:my-2"> {children} </div> </div> </div> ); } type InteractiveStepProps = { children: ReactNode; isCompleted: boolean; isLast: boolean; onToggle: () => void; stepNumber: number; title: string; }; function InteractiveStep({ children, isCompleted, isLast, onToggle, stepNumber, title, }: InteractiveStepProps): React.ReactNode { return ( <div className="flex gap-4"> <div className="flex flex-col items-center"> <button className={cn( "flex size-8 items-center justify-center rounded-full text-sm font-bold transition-colors", isCompleted ? "bg-green-500 text-white" : "bg-primary text-primary-foreground", )} onClick={onToggle} type="button" > {isCompleted ? ( <svg className="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> ) : ( stepNumber )} </button> {!isLast && ( <div className={cn( "w-px flex-1 mt-2", isCompleted ? "bg-green-500" : "bg-border", )} /> )} </div> <div className={cn( "flex-1 pb-8 transition-opacity", isCompleted && "opacity-60", )} > <h4 className={cn( "font-semibold text-foreground mb-2", isCompleted && "line-through", )} > {title} </h4> <div className="text-sm text-muted-foreground [&>p]:mb-2 [&>pre]:my-2"> {children} </div> </div> </div> ); } export type StepByStepProps = { /** Heading tag for the main title. Defaults to `h3`. */ as?: HeadingTag; children: ReactNode; className?: string; interactive?: boolean; title?: string; }; // eslint-disable-next-line max-lines-per-function -- Complex component with interactive/non-interactive modes function StepByStep({ as: Heading = "h3", children, className, interactive = false, title, }: StepByStepProps): React.ReactNode { const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set()); const steps = Array.isArray(children) ? children : [children]; const toggleStep = (index: number): void => { const newCompleted = new Set(completedSteps); if (newCompleted.has(index)) newCompleted.delete(index); else newCompleted.add(index); setCompletedSteps(newCompleted); }; if (!interactive) { return ( <div className={cn("my-6", className)}> {title ? ( <div className="flex items-center gap-2 mb-4"> <svg className="size-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="m9 18 6-6-6-6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> <Heading className="font-semibold text-lg">{title}</Heading> </div> ) : null} <div className="space-y-0"> {steps.map((step, index) => { const stepElement = step as React.ReactElement<StepProps>; const stepKey = `${stepElement.props.title}-${index + 1}`; return ( <Step key={stepKey} number={index + 1} title={stepElement.props.title} > {stepElement.props.children} </Step> ); })} </div> </div> ); } return ( <div className={cn("my-6", className)}> {title ? ( <div className="flex items-center gap-2 mb-4"> <svg className="size-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="m9 18 6-6-6-6" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> <h3 className="font-semibold text-lg">{title}</h3> <span className="text-xs text-muted-foreground ml-auto"> {completedSteps.size}/{steps.length} completed </span> </div> ) : null} <div className="space-y-0"> {steps.map((step, index) => ( <InteractiveStep isCompleted={completedSteps.has(index)} isLast={index === steps.length - 1} key={`${(step as React.ReactElement<StepProps>).props.title}-${index + 1}`} onToggle={() => { toggleStep(index); }} stepNumber={index + 1} title={(step as React.ReactElement<StepProps>).props.title} > {(step as React.ReactElement<StepProps>).props.children} </InteractiveStep> ))} </div> </div> ); } StepByStep.Step = Step; export { Step, StepByStep };

Dependencies

  • @vllnt/ui@^0.2.1