Stepper

Install Guide

Vertical, non-linear install checklist with a shadcn CLI command in a monospace block under each open step. Done, next ticks the step and moves focus to the following title.

Free, MIT licenseOpen in Browse
"use client"

import { useEffect, useId, useRef, useState } from "react"
import { CheckIcon } from "@phosphor-icons/react"
import { cn } from "cn"

import {
  Stepper,
  StepperContent,
  StepperDescription,
  StepperIndicator,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
  StepperTrigger,
} from "@/components/stepper/stepper"
import { Button } from "@/components/ui/button"

// The copy: change text here, not in the markup.
const COPY = {
  title: "Install the registry",
  body: "Three commands. Mark each one done as you go.",
  done: "Done, next",
  finished: "All set. Browse the registry to add more.",
  steps: [
    {
      title: "Initialise shadcn",
      body: "Creates components.json and the ui folder.",
      cmd: "pnpm dlx shadcn@latest init",
    },
    {
      title: "Add the registry",
      body: "Points the CLI at Orbcore.",
      cmd: "pnpm dlx shadcn@latest registry add @01orbital",
    },
    {
      title: "Install a block",
      body: "Any block, one command.",
      cmd: "pnpm dlx shadcn@latest add @01orbital/checkout",
    },
  ],
} as const

// Vertical with the command inline under the open step. Non-linear, so a
// reader can jump back to copy a command again.
export default function InstallGuide() {
  const [step, setStep] = useState(0)
  // Done is its own set, so jumping back to copy a command does not undo it.
  const [done, setDone] = useState<number[]>([])
  const id = useId()
  const focusNext = useRef(false)
  // Done, next unmounts the button under the pointer; focus lands on the
  // next step's title instead of falling to the body.
  useEffect(() => {
    if (!focusNext.current) return
    focusNext.current = false
    ;(
      document.getElementById(`${id}step-${step}`) ??
      document.getElementById(`${id}finished`)
    )?.focus()
  }, [id, step])
  const finish = (i: number) => {
    const nextDone = done.includes(i) ? done : [...done, i]
    setDone(nextDone)
    const nextStep = COPY.steps.findIndex(
      (_, index) => !nextDone.includes(index)
    )
    focusNext.current = true
    setStep(nextStep === -1 ? COPY.steps.length : nextStep)
  }
  const finished = done.length === COPY.steps.length
  return (
    <section className="mx-auto w-full max-w-md">
      <h2 className="text-xl font-semibold tracking-tight">{COPY.title}</h2>
      <p className="mt-1 mb-6 text-sm text-muted-foreground">{COPY.body}</p>
      <Stepper value={step} onValueChange={setStep} linear={false}>
        <StepperList className="flex-col items-stretch">
          {COPY.steps.map((s, i) => (
            <StepperItem
              key={s.title}
              value={i}
              className="flex-col items-stretch"
            >
              <div className="flex gap-3">
                <div className="flex flex-col items-center">
                  <StepperIndicator
                    className={cn(
                      "size-6 text-[11px]",
                      done.includes(i) &&
                        "bg-primary text-primary-foreground ring-0"
                    )}
                  >
                    {done.includes(i) ? (
                      <CheckIcon weight="bold" aria-hidden className="size-3" />
                    ) : (
                      <span>{i + 1}</span>
                    )}
                  </StepperIndicator>
                  <StepperSeparator className="mx-0 my-1 h-auto w-px flex-1 group-last/step:hidden" />
                </div>
                <div className="min-w-0 flex-1 pt-0.5 pb-6">
                  <StepperTrigger
                    id={`${id}step-${i}`}
                    className="h-auto justify-start px-0 py-0 text-left hover:bg-transparent"
                  >
                    <StepperTitle>{s.title}</StepperTitle>
                  </StepperTrigger>
                  <StepperContent value={i} className="mt-1 gap-3">
                    <StepperDescription>{s.body}</StepperDescription>
                    <pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs leading-6">
                      {s.cmd}
                    </pre>
                    <Button
                      size="sm"
                      className="w-fit"
                      onClick={() => finish(i)}
                    >
                      {COPY.done}
                    </Button>
                  </StepperContent>
                </div>
              </div>
            </StepperItem>
          ))}
        </StepperList>
        {finished && (
          <p
            id={`${id}finished`}
            tabIndex={-1}
            className="text-sm outline-none"
          >
            {COPY.finished}
          </p>
        )}
      </Stepper>
    </section>
  )
}

Installation

pnpm dlx shadcn add @01orbital/install-guide
npx shadcn add @01orbital/install-guide
yarn shadcn add @01orbital/install-guide
bunx --bun shadcn add @01orbital/install-guide

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/blocks/stepper/install-guide/index.tsx, as source you own and edit.

Usage

import InstallGuide from "@/components/blocks/stepper/install-guide"

export default function Example() {
  return <InstallGuide />
}

Dependencies

npm: @phosphor-icons/react, cn. The CLI installs them for you.

Registry pieces it installs with it: