Step By Step

Numbered step guide with optional interactive completion tracking. Install Step By Step from the VLLNT UI registry with the shadcn CLI.

Report a bug

Numbered step guide with optional interactive completion tracking. Part of the learning family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

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

Installation

Add Step By Step to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

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

Source

"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 };

Stories

Explore every variant and state in the interactive Storybook:

Preview

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

Dependencies

  • @vllnt/ui@^0.3.0