shadimage

Stepper

A composable step navigation pattern for guiding people through a small, ordered workflow.

registry/sonaui/stepper/stepper.tsx

"use client";

import {
  createContext,
  type HTMLAttributes,
  type ReactNode,
  useContext,
  useId,
  useState,
} from "react";
import { cn } from "@/lib/sona-utils";

type StepperContextValue = {
  value: number;
  setValue: (value: number) => void;
  orientation: "horizontal" | "vertical";
};

type StepperItemContextValue = {
  step: number;
  active: boolean;
  id: string;
};

const StepperContext = createContext<StepperContextValue | null>(null);
const StepperItemContext = createContext<StepperItemContextValue | null>(null);

function useStepper(component: string) {
  const context = useContext(StepperContext);
  if (!context) throw new Error(`${component} must be used within Stepper.`);
  return context;
}

function useStepperItem(component: string) {
  const context = useContext(StepperItemContext);
  if (!context)
    throw new Error(`${component} must be used within StepperItem.`);
  return context;
}

export interface StepperProps extends HTMLAttributes<HTMLDivElement> {
  /** Step navigation and panels. */
  children: ReactNode;
  /** The active step in controlled mode. */
  value?: number;
  /** The initially active step in uncontrolled mode. @default 1 */
  defaultValue?: number;
  /** Called when a user selects a different step. */
  onValueChange?: (value: number) => void;
  /** The direction of the step navigation. @default "horizontal" */
  orientation?: "horizontal" | "vertical";
}

export function Stepper({
  children,
  value,
  defaultValue = 1,
  onValueChange,
  orientation = "horizontal",
  className,
  ...props
}: StepperProps) {
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
  const activeValue = value ?? uncontrolledValue;
  const setValue = (nextValue: number) => {
    if (value === undefined) setUncontrolledValue(nextValue);
    onValueChange?.(nextValue);
  };

  return (
    <StepperContext.Provider
      value={{ value: activeValue, setValue, orientation }}
    >
      <div
        data-orientation={orientation}
        className={cn(
          "grid gap-6 data-[orientation=vertical]:md:grid-cols-[minmax(11rem,0.35fr)_minmax(0,1fr)]",
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </StepperContext.Provider>
  );
}

export function StepperNav({
  children,
  className,
  ...props
}: HTMLAttributes<HTMLElement>) {
  const { orientation } = useStepper("StepperNav");
  return (
    <nav
      aria-label="Steps"
      className={cn(
        "relative flex min-w-0 gap-2 data-[orientation=vertical]:flex-col data-[orientation=vertical]:before:absolute data-[orientation=vertical]:before:top-5 data-[orientation=vertical]:before:bottom-5 data-[orientation=vertical]:before:left-5 data-[orientation=vertical]:before:w-px data-[orientation=vertical]:before:bg-border",
        className,
      )}
      data-orientation={orientation}
      {...props}
    >
      {children}
    </nav>
  );
}

export interface StepperItemProps extends HTMLAttributes<HTMLDivElement> {
  /** The one-based value that identifies this step. */
  step: number;
  /** Step trigger and optional separator. */
  children: ReactNode;
}

export function StepperItem({
  step,
  children,
  className,
  ...props
}: StepperItemProps) {
  const { value } = useStepper("StepperItem");
  const id = useId();
  return (
    <StepperItemContext.Provider value={{ step, active: value === step, id }}>
      <div className={cn("flex min-w-0 items-center", className)} {...props}>
        {children}
      </div>
    </StepperItemContext.Provider>
  );
}

export function StepperTrigger({
  children,
  className,
  onClick,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const { setValue } = useStepper("StepperTrigger");
  const { step, active, id } = useStepperItem("StepperTrigger");
  return (
    <button
      type="button"
      aria-current={active ? "step" : undefined}
      aria-pressed={active}
      aria-describedby={`${id}-label`}
      onClick={(event) => {
        setValue(step);
        onClick?.(event);
      }}
      className={cn(
        "group flex min-w-0 items-center gap-3 rounded-lg px-2 py-1.5 text-left text-sm text-muted-foreground transition-colors hover:text-foreground hover:cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
        active && "text-foreground",
        className,
      )}
      {...props}
    >
      {children}
    </button>
  );
}

export function StepperIndicator({
  children,
  className,
  ...props
}: HTMLAttributes<HTMLSpanElement>) {
  const { active } = useStepperItem("StepperIndicator");
  return (
    <span
      className={cn(
        "grid size-6 shrink-0 place-items-center rounded-full border border-border bg-background font-mono text-[10px] text-muted-foreground transition-colors",
        "relative z-1",
        active && "border-primary bg-primary text-primary-foreground",
        className,
      )}
      {...props}
    >
      {children}
    </span>
  );
}

export function StepperTitle({
  children,
  className,
  ...props
}: HTMLAttributes<HTMLSpanElement>) {
  const { id } = useStepperItem("StepperTitle");
  return (
    <span id={`${id}-label`} className={cn("truncate", className)} {...props}>
      {children}
    </span>
  );
}

export function StepperSeparator({ className }: { className?: string }) {
  return (
    <span
      aria-hidden="true"
      className={cn("mx-1 h-px min-w-4 flex-1 bg-border", className)}
    />
  );
}

export function StepperPanel({
  children,
  className,
  ...props
}: HTMLAttributes<HTMLDivElement>) {
  return (
    <div className={cn("min-w-0", className)} {...props}>
      {children}
    </div>
  );
}

export interface StepperContentProps extends HTMLAttributes<HTMLDivElement> {
  /** The step value associated with this panel. */
  value: number;
  /** Keeps inactive content mounted. @default false */
  forceMount?: boolean;
  /** Content shown for this step. */
  children: ReactNode;
}

export function StepperContent({
  value,
  forceMount = false,
  children,
  className,
  ...props
}: StepperContentProps) {
  const { value: activeValue } = useStepper("StepperContent");
  const active = activeValue === value;
  if (!active && !forceMount) return null;

  return (
    <div
      hidden={!active && !forceMount}
      aria-hidden={!active && !forceMount}
      className={cn("min-w-0", className)}
      {...props}
    >
      {children}
    </div>
  );
}

export default Stepper;
Where it goes
onboarding