Step By Step
Numbered step guide with optional interactive completion tracking. Install Step By Step from the VLLNT UI registry with the shadcn CLI.
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.jsonSource
"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.