shadimage

Steps

Numbered step component with connecting line for sequential instructions.

docs/steps.tsx

'use client'

import { cn } from '../utils'
import { Check, Circle } from 'lucide-react'
import React, { useMemo } from 'react'

// =============================================================================
// Steps Component
// =============================================================================

interface StepInfo {
    title: string
    children: React.ReactNode
    icon?: React.ReactNode
}

function extractSteps(children: React.ReactNode): StepInfo[] {
    const steps: StepInfo[] = []

    React.Children.forEach(children, (child) => {
        if (!React.isValidElement(child)) return

        const type = child.type
        const typeName = typeof type === 'string' ? type.toLowerCase() : ''

        if (typeName === 'step' || (typeof type === 'function' && (type as React.FC).displayName === 'DocsStep')) {
            const props = child.props as { title?: string; icon?: React.ReactNode; children?: React.ReactNode }
            steps.push({
                title: props.title || '',
                icon: props.icon,
                children: props.children,
            })
        }
    })

    return steps
}

export interface StepsProps {
    children: React.ReactNode
    className?: string
    /** Title text size: 'p' (default), 'h2', 'h3' */
    titleSize?: 'p' | 'h2' | 'h3'
}

export function Steps({ children, className, titleSize = 'p' }: StepsProps) {
    const steps = useMemo(() => extractSteps(children), [children])

    if (steps.length === 0) {
        return <div className={className}><span>{children}</span></div>
    }

    const titleClasses = {
        'p': 'text-base font-semibold',
        'h2': 'text-xl font-bold',
        'h3': 'text-lg font-semibold',
    }

    return (
        <div className={cn('my-6 space-y-0', className)}>
            {steps.map((step, i) => (
                <div key={i} className="relative flex gap-4 pb-8 last:pb-0">
                    {/* Vertical line */}
                    {i < steps.length - 1 && (
                        <div className="absolute left-[15px] top-8 h-[calc(100%-16px)] w-px bg-border" />
                    )}

                    {/* Step number indicator */}
                    <div className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 border-brand-pink bg-background text-sm font-semibold text-brand-pink">
                        {step.icon || i + 1}
                    </div>

                    {/* Content */}
                    <div className="flex-1 pt-0.5">
                        {step.title && (
                            <div className={cn(titleClasses[titleSize], 'mb-2')}>
                                {step.title}
                            </div>
                        )}
                        <div className="text-sm text-muted-foreground prose prose-sm dark:prose-invert max-w-none">
                            {step.children}
                        </div>
                    </div>
                </div>
            ))}
        </div>
    )
}

// =============================================================================
// Step Component (individual step)
// =============================================================================

export interface StepProps {
    title?: string
    icon?: React.ReactNode
    children: React.ReactNode
}

export function Step({ children }: StepProps) {
    // This component is just a marker - actual rendering happens in Steps
    return <>{children}</>
}

Step.displayName = 'DocsStep'
Where it goes
onboarding