Skip to content

feat(stepper): new stepper component #318

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 144 commits into
base: main
Choose a base branch
from

Conversation

damianricobelli
Copy link
Contributor

@damianricobelli damianricobelli commented May 8, 2023

Hi! In this opportunity I present a new component: Stepper.

The idea of this in its beginnings was to make it as modular and flexible as possible for development.

A basic example of the application is this:

const steps = [
  { label: "Step 1" },
  { label: "Step 2" },
  { label: "Step 3" },
] satisfies StepConfig[]

export const StepperDemo = () => {
  const {
    nextStep,
    prevStep,
    resetSteps,
    setStep,
    activeStep,
    isDisabledStep,
    isLastStep,
    isOptionalStep,
  } = useStepper({
    initialStep: 0,
    steps,
  })

  return (
    <>
      <Steps activeStep={activeStep}>
        {steps.map((step, index) => (
          <Step index={index} key={index} {...step}>
            <div className="bg-muted h-40 w-full p-4">
              <p>Step {index + 1} content</p>
            </div>
          </Step>
        ))}
      </Steps>
      <div className="flex items-center justify-end gap-2">
        {activeStep === steps.length ? (
          <>
            <h2>All steps completed!</h2>
            <Button onClick={resetSteps}>Reset</Button>
          </>
        ) : (
          <>
            <Button disabled={isDisabledStep} onClick={prevStep}>
              Prev
            </Button>
            <Button onClick={nextStep}>
              {isLastStep ? "Finish" : isOptionalStep ? "Skip" : "Next"}
            </Button>
          </>
        )}
      </div>
    </>
  )
}

Here is a complete video of the different use cases:

Grabacion.de.pantalla.2023-05-08.a.la.s.13.14.45.mov

@vercel
Copy link

vercel bot commented May 8, 2023

@damianricobelli is attempting to deploy a commit to the shadcn-pro Team on Vercel.

A member of the Team first needs to authorize it.

@damianricobelli damianricobelli changed the title feat(stepper): add component with docs feat(stepper): new stepper component May 8, 2023
@vercel
Copy link

vercel bot commented May 8, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
ui ✅ Ready (Inspect) Visit Preview 💬 Add feedback Mar 21, 2025 7:53am
v4 ✅ Ready (Inspect) Visit Preview Mar 21, 2025 7:53am
1 Skipped Deployment
Name Status Preview Comments Updated (UTC)
next-template ⬜️ Ignored (Inspect) Visit Preview Mar 21, 2025 7:53am

@jocarrd
Copy link

jocarrd commented May 9, 2023

love it 👀

@shadcn
Copy link
Collaborator

shadcn commented May 9, 2023

This looks incredible @damianricobelli I'll review.

@its-monotype
Copy link
Contributor

its-monotype commented May 9, 2023

Looks amazing 😍 Unfortunately I don't have time to review and research about this component. However, look what I recently found: https://saas-ui.dev/docs/components/navigation/stepper.
https://github.com/saas-js/saas-ui/tree/main/packages/saas-ui-core/src/stepper

This can serve as reference to improve or borrow ideas to simplify the implementation. From what I can suggest it is to rename Step to StepperStep and useSteps to useStepper to comply with the general API conventions of the components and it will be more unique name to prevent conflicts.

@damianricobelli
Copy link
Contributor Author

Looks amazing 😍 Unfortunately I don't have time to review and research about this component. However, look what I recently found: https://saas-ui.dev/docs/components/navigation/stepper. https://github.com/saas-js/saas-ui/tree/main/packages%2Fsaas-ui-stepper

This can serve as reference to improve or borrow ideas to simplify the implementation. From what I can suggest it is to rename Step to StepperStep and useSteps to useStepper to comply with the general API conventions of the components and it will be more unique name to prevent conflicts.

Thank you very much for your feedback! I'll be reviewing tomorrow what you just shared and your suggestions 🫶

@damianricobelli
Copy link
Contributor Author

@shadcn What do you think about this component? Do you think we should adjust anything so that it can be launched on prod?

@destino92
Copy link

Is this is still in progress?

@damianricobelli
Copy link
Contributor Author

damianricobelli commented Jun 20, 2023

Is this is still in progress?

@destino92 From my side the component is ready. Just need to know if @shadcn agrees to move forward and add it to the CLI that brings and details that you think are missing in terms of documentation.

@drewhoffer
Copy link

This looks good!

@dan5py
Copy link
Contributor

dan5py commented Jun 30, 2023

Hi @damianricobelli, this component looks very good. Could you please update it to the new version (different themes, registry, docs, etc.)?

@damianricobelli
Copy link
Contributor Author

@dan5py yes of course. Between today and Monday I will be making the necessary changes so that the component allows the last addition you mention.

@damianricobelli
Copy link
Contributor Author

@shadcn Could you check this? I've already updated the code with all the latest stuff in the main branch. There are already several people watching the release of this component 🤩 🚀

@damianricobelli
Copy link
Contributor Author

damianricobelli commented Jul 4, 2023

Hi @damianricobelli, this component looks very good. Could you please update it to the new version (different themes, registry, docs, etc.)?

Done @dan5py! 🥳

@iamfj
Copy link

iamfj commented Feb 11, 2025

Wow, @damianricobelli, that was fast! 🚀 I haven’t tested it yet, but it looks super straightforward. However, I noticed that in your example, you didn’t use the stepper component from this PR, right? It seems like you built a much simpler version—looks great! But I was also hoping to test the scenario where I install your component via shadcn-cli and integrate it as expected from this PR.

In my opinion it is much simpler if you do the same but based on query params because you don't have to worry about errors in the pathname, you just know that there will be a value of the query params that will match or not with your steps.

You mentioned that the query params approach is much simpler. Do you also have an example showcasing this approach?

The main issue I wanted to tackle with route-based steps is that when a user hits the back button, it shouldn’t take them to the last visited page but rather to the previous step. I initially tried solving this with shallow routing, but it turned out to be overly complex—at least in my implementation. Maybe you have a cleaner, more straightforward solution in mind?

Looking forward to your thoughts! 😊

@damianricobelli
Copy link
Contributor Author

@iamfj Actually use the library on which the component of this PR is based for simplicity. You can achieve the same with the component of this PR without problems.

As for the query params example, I can create an example in the next few days.

And regarding modifying the back button to respond to the steps: you should simply have a custom button that uses the back method of the useStepper of your instance.

@iamfj
Copy link

iamfj commented Feb 11, 2025

@damianricobelli Got it! I wasn’t referring to the stepper’s back button but rather the browser’s back button. When a user clicks it, I’d like it to navigate to the previous step instead of the last visited page.

@Yhozen
Copy link

Yhozen commented Feb 23, 2025

🚀

@bismitpanda
Copy link

@damianricobelli can you please use the new shadcn registry to host this somewhere (maybe on github) and give a link, so that we can shadcn add "<link>" this.

@rhakbari
Copy link

rhakbari commented Mar 4, 2025

when can we expect this stepper feature to be merged in shadcn ui ??? @shadcn

@shadcn
Copy link
Collaborator

shadcn commented Mar 4, 2025

I'll get back to new components right after the Tailwind v4 work.

@sanjaydotpro
Copy link

Now that we have tailwind v4, can we merge this please?

# Conflicts:
#	apps/www/package.json
#	pnpm-lock.yaml
@shadcn
Copy link
Collaborator

shadcn commented Mar 21, 2025

@damianricobelli I'm back reviewing this. Trying to see if we can simplify the API a bit and make it more composable. Questions:

Do StepperProvider, StepperNavigation...etc need to come from defineStepper?

I was wondering if we can have:

import {  StepperProvider, StepperNavigation, StepperStep } from "@/components/ui/stepper"

export function Example() {
  const { methods, steps, ...props } = useStepper({
    steps: [...]
  })

  return (
    <StepperProvider {...props}>
      <StepperNavigation>
        {steps.map((step) => (
          <StepperStep key={step.id} of={step.id} onClick={() => methods.goTo(step.id)}>
          
          </StepperStep>
        ))}
    </StepperProvider>
  )
}

@damianricobelli
Copy link
Contributor Author

damianricobelli commented Mar 21, 2025

@shadcn yes sir. They needs to come from defineStepper to keep the API typesafe. Unless you come up with another idea, I think it would be difficult to keep the types If we don't do it this way.

The API is inspired a little by how the typesafe handles the Tanstack Form hook, where you get a form component from the hook and provides you with components through form. . But everything is born on the hook. In this case, it is born in the defineStepper so that the instances can be reused in other routes

@damianricobelli
Copy link
Contributor Author

@shadcn I have been reviewing your question again and I don't think there is a way to separate the components of defineStepper. The reason is related to my previous comment: for the API to remain typesafe, we need to build these components based on the generics that traverse the defineStepper API

@damianricobelli
Copy link
Contributor Author

@shadcn if you have any more questions or would like me to work on something in particular about this PR, don't hesitate to send me a DM on X/Twitter 🤝

@justinfernald
Copy link

image
The vertical version doesn't have the lines aligned with the circles.

@landry-fairwinds
Copy link

I know it's long, but putting this here in case it helps. I created a wrapper around the API with React Context in order to make my dynamic form stepper more composable.

Dashboard Component:

'use client';

export function FormPresenterDashboard({
  formTitle = 'Form',
  formSteps,
}: { formTitle?: string; formSteps: FormStepperStep[] }) {
  const formStepper = defineStepper<FormStepperStep[]>(...formSteps);
  return (
    <FormStepper {...formStepper}>
      <AppSidebar
        header={
          <div className="flex items-end gap-2 p-2">
            <OrigamiIcon /> <span>Mentore</span>
          </div>
        }
        content={
          <SidebarMenu className="flex h-full flex-col gap-4 px-4 py-8">
            <FormStepperNavigation />
            <SidebarMenuItem className="mt-auto">
              <SidebarMenuButton asChild>
                <Link href="/dashboard">
                  <ArrowLeftIcon />
                  Back to Dashboard
                </Link>
              </SidebarMenuButton>
            </SidebarMenuItem>
          </SidebarMenu>
        }
      >
        <Header page={formTitle} />
        <div className="mx-auto flex h-full w-full max-w-4xl flex-col gap-8 p-6 pb-24">
          <FormStepperContent />
        </div>
      </AppSidebar>
    </FormStepper>
  );
}

Stepper Wrapper

type FormStepperStep = FormStepSchema;

type FormStepperType = DefineStepperProps<FormStepperStep[]>;

type FormStepperContextValue = FormStepperType;

const FormStepperContext = React.createContext<FormStepperContextValue | null>(
  null
);

const useFormStepper = () => {
  const onboardingStepperContext = React.useContext(FormStepperContext);
  if (!onboardingStepperContext) {
    throw new Error('useFormStepper must be used within a <FormStepper>');
  }
  return onboardingStepperContext;
};

function FormStepper({
  children,
  ...stepper
}: { children: React.ReactNode } & FormStepperContextValue) {
  const { StepperProvider } = stepper;
  return (
    <FormStepperContext.Provider value={stepper}>
      <StepperProvider className="space-y-4" variant="vertical">
        {children}
      </StepperProvider>
    </FormStepperContext.Provider>
  );
}

function FormStepperNavigation() {
  const { StepperNavigation, StepperStep, StepperTitle, useStepper } =
    useFormStepper();
  const methods = useStepper();
  return (
    <StepperNavigation>
      {methods.all.map((step) => (
        <StepperStep key={step.id} of={step.id} className="pointer-events-none">
          <StepperTitle>{step.title}</StepperTitle>
        </StepperStep>
      ))}
    </StepperNavigation>
  );
}

function FormStepperContent() {
  const { StepperPanel, useStepper } = useFormStepper();
  const methods = useStepper();
  return (
    <div>
      {methods.when(methods.current.id, (step) => (
        <StepperPanel>
          <Card>
            <CardHeader>
              <CardTitle>{step.title}</CardTitle>
              <CardDescription>{step.description}</CardDescription>
            </CardHeader>
            <CardContent>
              <FormStep {...step} />
            </CardContent>
          </Card>
        </StepperPanel>
      ))}
    </div>
  );
}

function FormStepperControls() {
  const { StepperControls, useStepper } = useFormStepper();
  const methods = useStepper();
  return (
    <StepperControls className="mt-auto">
      <Button
        variant="secondary"
        onClick={methods.prev}
        disabled={methods.isFirst}
      >
        Previous
      </Button>
      <Button onClick={methods.next} disabled={methods.isLast}>
        {'Next'}
      </Button>
    </StepperControls>
  );
}

export {
  FormStepper,
  FormStepperNavigation,
  FormStepperContent,
  FormStepperControls,
  type FormStepperStep,
  type FormStepperType,
  useFormStepper,
};

This allows for using stepper controls in my form steps.

export function FormStep(formStep: FormStepSchema) {
  const { useStepper } = useFormStepper();
  const formStepper = useStepper();
  const form = useForm({});
  const formStore = useFormStore((store) => store);

  async function onSubmit(data: Record<string, string>) {
    await delay(200); // Simulate async
    const payload = { ...data, ...formStore.data };
    if (formStepper.isLast) {
      console.log(payload); // Submit form
    } else {
      formStore.setData(payload);
      formStepper.next(); // Form stepper method
    }
  }

  const colSpan = {
    1: 'col-span-1',
    2: 'col-span-2',
    3: 'col-span-3',
    4: 'col-span-4',
  };

  return (
    <FormStepContext.Provider value={form}>
      <Form {...form}>
        <form
          onSubmit={form.handleSubmit(onSubmit)}
          className="grid grid-cols-4 gap-6"
        >
          {formStep.fields.map((field) => (
            <div className={cn(colSpan[field.colSpan ?? '4'])} key={field.id}>
              <FormField {...field} />
            </div>
          ))}

          <div className="col-span-4 flex flex-col-reverse gap-3 lg:ml-auto lg:w-fit lg:flex-row">
            {!formStepper.isFirst && (
              <Button
                onClick={formStepper.prev}
                type="button"
                variant={'secondary'}
              >
                Previous
              </Button>
            )}
            <LoadingButton disabled={form.formState.isSubmitting} type="submit">
              {formStep.buttonLabel}
            </LoadingButton>
          </div>
        </form>
      </Form>
    </FormStepContext.Provider>
  );
}

Screenshot 2025-04-07 at 11 37 50 AM

@amoretti-dev
Copy link

amoretti-dev commented Apr 15, 2025

Hi everyone, I will post here my implementation based on the amazing library from @damianricobelli. Thank you so much for your job! I love it!

I definetly tried to reproduce your example in your page (https://stepperize.vercel.app/)
So maybe this can help someone while @shadcn will release this component.

stepper.tsx

import { Step, Stepper, StepperReturn } from "@stepperize/react";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "./button";
import { CheckCircle, LucideIcon } from "lucide-react";
import clsx from "clsx";
import { Fragment, ReactNode } from "react";

export interface StepperStep extends Step {
  id: string;
  icon: LucideIcon;
  title: string;
  // stepContent: ReactNode;
}

export type GetStepContentFunc = (
  stepperApi: Stepper<StepperStep[]>
) => ReactNode;

type ScopedStepperProps = {
  stepper: StepperReturn<StepperStep[]>;
};
type ScopedStepperContentProps = {
  stepContents: Record<string, GetStepContentFunc>;
} & ScopedStepperProps;

export const ScopedStepper: React.FC<ScopedStepperContentProps> = ({
  stepper,
  stepContents,
}) => {
  return (
    <div className="backdrop-blur-sm border rounded-xl overflow-hidden shadow-xl w-full">
      <stepper.Scoped>
        <StepperSteps stepper={stepper} />
        <div className="p-8">
          <ScopedStepContent stepper={stepper} stepContents={stepContents} />
          <StepNavigation stepper={stepper} />
        </div>
      </stepper.Scoped>
    </div>
  );
};

function StepperSteps({ stepper }: ScopedStepperProps) {
  const { utils, useStepper } = stepper;
  const _stepper = useStepper();
  const currentIndex = utils.getIndex(_stepper.current.id);
  return (
    <nav className="bg-slate-800/80 p-8">
      <ol className="flex justify-between items-center relative">
        <div className="absolute top-5 left-7 xs:left-12 right-7 h-0.5 bg-slate-900 z-0 w-[93%]">
          <motion.div
            initial={{ width: 0 }}
            animate={{
              width: `${(currentIndex / (utils.getAll().length - 1)) * 100}%`,
            }}
            transition={{ duration: 0.4 }}
            className="h-full bg-primary"
          ></motion.div>
        </div>

        {_stepper.all.map((step, index) => {
          const isCompleted = index < currentIndex;
          const isActive = index === currentIndex;
          return (
            <motion.div
              key={step.id}
              initial={false}
              animate={{ scale: isActive ? 1.1 : 1 }}
              transition={{ type: "spring", stiffness: 300 }}
              className="flex flex-col items-center relative flex-shrink-0 z-10 transform-none"
            >
              <div
                className={clsx(
                  "size-10 rounded-full flex items-center justify-center cursor-pointer",
                  "transform-none",
                  isCompleted || isActive
                    ? "bg-primary border-none text-slate-300 dark:text-slate-800"
                    : "border-slate-300 text-slate-300 bg-white dark:bg-slate-800"
                  // "w-8 h-8 flex items-center justify-center rounded-full border-2 transition-colors duration-300 select-none"
                )}
              >
                <div className="transform-none">
                  {isCompleted ? (
                    <CheckCircle className="w-4 h-4" />
                  ) : (
                    <step.icon size={20} />
                  )}
                </div>
              </div>
              <span
                className={clsx(
                  "text-xs mt-2 hidden sm:block",
                  isCompleted || isActive
                    ? " text-primary/80"
                    : " text-slate-800 dark:text-slate-300"
                )}
              >
                {step.title}
              </span>
            </motion.div>
          );
        })}
      </ol>
    </nav>
  );
}

export function ScopedStepContent({
  stepper,
  stepContents,
}: ScopedStepperContentProps) {
  const _stepper = stepper.useStepper();
  return (
    <Fragment>
      <AnimatePresence mode="wait">
        <motion.div
          initial={{ opacity: 0, x: 40 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -40 }}
          transition={{ duration: 0.4 }}
          className="w-full"
        >
          <h3 className="text-xl font-semibold mb-6 text-gray-12">
            {_stepper.current.title}
          </h3>
          {stepContents[_stepper.current.id](_stepper)}
        </motion.div>
      </AnimatePresence>
    </Fragment>
  );
}

function StepNavigation({ stepper }: ScopedStepperProps) {
  const _stepper = stepper.useStepper();
  return (
    <div className="mt-8 flex justify-between">
      {!_stepper.isFirst && (
        <Button onClick={_stepper.prev} type="button">
          Indietro
        </Button>
      )}
      <Button
        onClick={_stepper.isLast ? undefined : _stepper.next}
        className="ml-auto"
      >
        {_stepper.isLast ? "Conferma" : "Avanti"}
      </Button>
    </div>
  );
}

Usage

"use client";

import {
  GetStepContentFunc,
  ScopedStepper,
  StepperStep,
} from "@/components/ui/stepper";
import { defineStepper, Stepper } from "@stepperize/react";
import { User, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function RegistrationStepper() {
  const steps: StepperStep[] = [
    {
      id: "first",
      title: "This is the first step",
      icon: User,
    },
    {
      id: "second",
      title: "This is the second step",
      icon: Tag,
    },
    {
      id: "third",
      title: "This is the third step",
      icon: User,
    },
    {
      id: "last",
      title: "This is the last step",
      icon: Tag,
    },
  ];

  const stepper = defineStepper(...steps);

  const stepperContents: Record<string, GetStepContentFunc> = {
    first: (stepper: Stepper<StepperStep[]>) => (
      <>
        <p>First step: {stepper.current.title}</p>
        <Button onClick={stepper.next}>Test</Button>
      </>
    ),
    second: (stepper: Stepper<StepperStep[]>) => (
      <p>Second step: {stepper.current.title}</p>
    ),
    third: (stepper: Stepper<StepperStep[]>) => (
      <>
        <p>Third step: {stepper.current.title}</p>
        <Button onClick={stepper.prev}>Test</Button>
      </>
    ),
    last: (stepper: Stepper<StepperStep[]>) => (
      <p>Third step: {stepper.current.title}</p>
    ),
  };
  return (
    <div className="p-4 flex justify-center">
      <ScopedStepper stepper={stepper} stepContents={stepperContents} />
    </div>
  );
}

If you prefer, you can move your content directly to your steps:

{
      id: "first",
      title: "This is the first step",
      icon: User,
      content: (stepper: Stepper<StepperStep[]>) => (
        <>
          <p>First step: {stepper.current.title}</p>
          <Button onClick={stepper.next}>Test</Button>
        </>
      )
},

then in your stepper.tsx:

call in the ScopedStepperContent

{_stepper.current.content(_stepper)}

@rhakbari
Copy link

@shadcn when will this PR get reviewed and merged in the main branch as stepper is an important component which is needed plenty of applications

@damianricobelli
Copy link
Contributor Author

Hey @rhakbari, I understand your frustration, but we must understand that @shadcn is probably working on other things or simply does not have the time necessary to thoroughly review this PR. If you still need the component, you are free to take it from the PR and use it in your apps as many others have already done. And if you encounter any problems, you can simply create an issue in the stepperize repo or comment here

@leog
Copy link

leog commented Apr 20, 2025

With all due respect @damianricobelli, I don't think this will ever be merged into shadcn-ui. You released this as a separate library and the whole idea behind shadcn-ui is that whatever it is included are just very basic UI elements. It already provides a way to install other components from different registries, so the stepper you so carefully designed can already be used and installed independently. I wouldn't think merging the stepper as it is makes sense. Maybe @shadcn can bring some light here about the future of this PR.

@damianricobelli
Copy link
Contributor Author

@leog thank you very much for your comment. In fact, we have already been in contact and in the next few days there will be a release about this through shadcn cli.

Regarding your comment that shadcn only includes simple components, this is actually not so true since shadcn also includes tanstack table or otp input abstractions. So actually stepperize has all the possibilities to be part of shadcn, but in the end we will go another way after my conversation with @shadcn

@damianricobelli
Copy link
Contributor Author

I am very happy to introduce ✨ Shadcn Stepper ✨. An abstraction of my stepperize library to create step-by-step flows with typesafe API integrated with shadcn cli and v0.

https://x.com/damianricobelli/status/1914344064641626227

@shadcn Let's see how this abstraction goes 🙌

@leog
Copy link

leog commented Apr 21, 2025

@damianricobelli thanks for the clarification and the update, I guess this PR can be closed then 👍
thanks for your work Damian, we all needed a stepper!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: roadmap This looks great. We'll add it to the roadmap, review and merge. new component
Projects
None yet
Development

Successfully merging this pull request may close these issues.