my/ui

Command Palette

Search for a command to run...

All components

Onboarding

navigation

Composable multi-step onboarding primitives: Onboarding root with step navigation, FeatureCarousel, ChoiceGroup radio selector, TipsList, and StepIndicator with dots and pills variants

responsive · 500px

Install

Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:

$npx shadcn@latest add https://your-domain/r/onboarding.json

Usage

"use client"

import { Onboarding } from "@/registry/cult-ui/onboarding"

export default function Demo() {
  return (
    <div className="flex items-center justify-center w-full min-h-[420px] p-6 bg-background">
      <Onboarding.Root totalSteps={3} defaultValue={1} className="w-full max-w-sm">
        <Onboarding.StepIndicator className="mb-6 justify-center" />

        <Onboarding.Step step={1}>
          <Onboarding.Header
            title="Welcome to myui"
            description="A personal component library built for speed and aesthetics."
          />
          <div className="mt-4 rounded-lg border border-border bg-muted/40 p-4 text-sm text-muted-foreground">
            Browse, preview, and install polished components directly into your
            project with a single CLI command.
          </div>
        </Onboarding.Step>

        <Onboarding.Step step={2}>
          <Onboarding.Header
            title="Pick your style"
            description="Components are minimal and monochrome by default — ready for your theme."
          />
          <div className="mt-4 flex flex-col gap-2">
            {["Minimal", "Vibrant", "System default"].map((option) => (
              <div
                key={option}
                className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-sm font-medium text-foreground cursor-pointer hover:bg-muted/50"
              >
                <span className="size-3 rounded-full border border-muted-foreground/50" />
                {option}
              </div>
            ))}
          </div>
        </Onboarding.Step>

        <Onboarding.Step step={3}>
          <Onboarding.Header
            title="You're all set"
            description="Start exploring components or install your first one now."
          />
          <div className="mt-4 rounded-lg border border-border bg-muted/40 p-4 text-sm text-muted-foreground">
            Run{" "}
            <code className="font-mono text-foreground">npx shadcn add &lt;component&gt;</code>{" "}
            to install any component from this library.
          </div>
        </Onboarding.Step>

        <Onboarding.Navigation className="mt-6" />
      </Onboarding.Root>
    </div>
  )
}

Component source

"use client"

import type * as React from "react"
import {
  Children,
  createContext,
  useCallback,
  useContext,
  useId,
  useMemo,
  type PropsWithChildren,
} from "react"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

const stepIndicatorVariants = cva("flex items-center justify-center gap-2", {
  variants: {
    variant: {
      dots: "",
      pills: "",
    },
  },
  defaultVariants: {
    variant: "dots",
  },
})

const stepDotVariants = cva("rounded-full transition-all duration-200", {
  variants: {
    variant: {
      dots: "size-2 data-[state=active]:size-2.5 data-[state=active]:bg-foreground data-[state=completed]:bg-foreground/60 data-[state=inactive]:bg-muted-foreground/30",
      pills:
        "h-1 max-w-8 flex-1 rounded-full data-[state=active]:bg-foreground data-[state=completed]:bg-foreground/60 data-[state=inactive]:bg-muted-foreground/30",
    },
  },
  defaultVariants: {
    variant: "dots",
  },
})

export interface StepIndicatorProps
  extends React.ComponentPropsWithoutRef<"div">,
    VariantProps<typeof stepIndicatorVariants>,
    VariantProps<typeof stepDotVariants> {
  /** Current step index (1-based) */
  currentStep: number
  /** Total number of steps */
  totalSteps: number
  /** Optional className for each step dot */
  dotClassName?: string
}

/**
 * Headless step indicator primitive.
 * Renders a list of step dots with proper ARIA for progress indication.
 * No visual styling—consumer provides via className.
 */
export function StepIndicator({
  currentStep,
  totalSteps,
  variant = "dots",
  className,
  dotClassName,
  ...props
}: StepIndicatorProps) {
  return (
    <div
      aria-label={`Step ${currentStep} of ${totalSteps}`}
      aria-valuemax={totalSteps}
      aria-valuemin={1}
      aria-valuenow={currentStep}
      className={cn(stepIndicatorVariants({ variant }), className)}
      data-slot="onboarding-step-indicator"
      role="progressbar"
      {...props}
    >
      {Array.from({ length: totalSteps }, (_, i) => {
        const stepNumber = i + 1
        const isActive = currentStep === stepNumber
        const isCompleted = currentStep > stepNumber
        let stepState: "active" | "completed" | "inactive" = "inactive"
        if (isActive) {
          stepState = "active"
        } else if (isCompleted) {
          stepState = "completed"
        }
        return (
          <div
            aria-current={isActive ? "step" : undefined}
            className={cn(stepDotVariants({ variant }), dotClassName)}
            data-slot="onboarding-step-dot"
            data-state={stepState}
            key={stepNumber}
          />
        )
      })}
    </div>
  )
}

// ============================================================================
// Types
// ============================================================================

export interface OnboardingContextValue {
  /** Current step index (1-based) */
  currentStep: number
  /** Total number of steps */
  totalSteps: number
  /** Sub-step value (e.g. feature carousel index within step 1) */
  stepValue: number
  /** Set current step */
  setStep: (step: number | ((prev: number) => number)) => void
  /** Set step value (sub-step) */
  setStepValue: (value: number | ((prev: number) => number)) => void
  /** Max step value for current step (e.g. feature count - 1) */
  maxStepValue: number
  /** Whether user can proceed to next */
  canGoNext: boolean
  /** Whether user can go back */
  canGoBack: boolean
  /** Navigate to previous step */
  handleBack: () => void
  /** Navigate to next step or advance sub-step */
  handleNext: () => void
  /** Complete onboarding */
  handleComplete: () => void
  /** Callback when onboarding is completed */
  onComplete?: () => void
}

// ============================================================================
// Context
// ============================================================================

const OnboardingContext = createContext<OnboardingContextValue | null>(null)

function useOnboarding() {
  const ctx = useContext(OnboardingContext)
  if (!ctx) {
    throw new Error("Onboarding components must be used within Onboarding.Root")
  }
  return ctx
}

// ============================================================================
// Root
// ============================================================================

export interface OnboardingRootProps
  extends PropsWithChildren,
    Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
  /** Controlled step index (1-based) */
  value?: number
  /** Default step index (uncontrolled) */
  defaultValue?: number
  /** Callback when step changes */
  onValueChange?: (step: number) => void
  /** Controlled sub-step value */
  stepValue?: number
  /** Default sub-step value (uncontrolled) */
  defaultStepValue?: number
  /** Callback when sub-step value changes */
  onStepValueChange?: (value: number) => void
  /** Total number of steps */
  totalSteps: number
  /** Max sub-step value for step 1 (e.g. feature count - 1). Default 0 = no sub-steps */
  maxStepValue?: number
  /** Callback when onboarding is completed */
  onComplete?: () => void
  /** Custom logic for whether user can proceed. Receives (step, stepValue). Default: true */
  canGoNext?: (step: number, stepValue: number) => boolean
}

function OnboardingRoot({
  value: controlledValue,
  defaultValue = 1,
  onValueChange,
  stepValue: controlledStepValue,
  defaultStepValue = 0,
  onStepValueChange,
  totalSteps,
  maxStepValue: controlledMaxStepValue = 0,
  onComplete,
  canGoNext: canGoNextFn,
  children,
  className,
  ...props
}: OnboardingRootProps) {
  const [currentStep, setCurrentStep] = useControllableState({
    prop: controlledValue,
    defaultProp: defaultValue,
    onChange: onValueChange,
  })

  const [stepValue, setStepValueState] = useControllableState({
    prop: controlledStepValue,
    defaultProp: defaultStepValue,
    onChange: onStepValueChange,
  })

  const maxStepValue = controlledMaxStepValue ?? 0

  const canGoNext = canGoNextFn ? canGoNextFn(currentStep, stepValue) : true

  const canGoBack = currentStep > 1 || stepValue > 0

  const handleNext = useCallback(() => {
    if (currentStep === 1 && stepValue < maxStepValue) {
      setStepValueState((prev) => prev + 1)
    } else if (currentStep < totalSteps) {
      setStepValueState(0)
      setCurrentStep((prev) => prev + 1)
    }
  }, [
    currentStep,
    stepValue,
    maxStepValue,
    totalSteps,
    setStepValueState,
    setCurrentStep,
  ])

  const handleBack = useCallback(() => {
    if (currentStep === 1 && stepValue > 0) {
      setStepValueState((prev) => prev - 1)
    } else if (currentStep === 2) {
      setCurrentStep(1)
      setStepValueState(maxStepValue)
    } else if (currentStep > 1) {
      setCurrentStep((prev) => prev - 1)
    }
  }, [currentStep, stepValue, maxStepValue, setStepValueState, setCurrentStep])

  const handleComplete = useCallback(() => {
    onComplete?.()
  }, [onComplete])

  const contextValue = useMemo<OnboardingContextValue>(
    () => ({
      currentStep,
      totalSteps,
      stepValue,
      setStep: setCurrentStep,
      setStepValue: setStepValueState,
      maxStepValue,
      canGoNext,
      canGoBack,
      handleBack,
      handleNext,
      handleComplete,
      onComplete,
    }),
    [
      currentStep,
      totalSteps,
      stepValue,
      setCurrentStep,
      setStepValueState,
      maxStepValue,
      canGoNext,
      canGoBack,
      handleBack,
      handleNext,
      handleComplete,
      onComplete,
    ]
  )

  return (
    <OnboardingContext.Provider value={contextValue}>
      <div
        className={cn(
          "flex flex-col rounded-xl border bg-background p-6 shadow-sm",
          className
        )}
        data-slot="onboarding"
        data-state={`step-${currentStep}`}
        {...props}
      >
        {children}
      </div>
    </OnboardingContext.Provider>
  )
}

// ============================================================================
// Step
// ============================================================================

export interface OnboardingStepProps
  extends React.ComponentPropsWithoutRef<"div"> {
  /** Step index (1-based) - content renders when currentStep matches */
  step: number
}

function OnboardingStep({
  step,
  children,
  className,
  ...props
}: OnboardingStepProps) {
  const { currentStep } = useOnboarding()
  const isActive = currentStep === step

  if (!isActive) {
    return null
  }

  return (
    <div
      className={cn(className)}
      data-slot="onboarding-step"
      data-state="active"
      {...props}
    >
      {children}
    </div>
  )
}

// ============================================================================
// StepIndicator
// ============================================================================

export interface OnboardingStepIndicatorProps
  extends Omit<
    React.ComponentProps<typeof StepIndicator>,
    "currentStep" | "totalSteps"
  > {}

function OnboardingStepIndicator(props: OnboardingStepIndicatorProps) {
  const { currentStep, totalSteps } = useOnboarding()
  return (
    <StepIndicator
      currentStep={currentStep}
      totalSteps={totalSteps}
      {...props}
    />
  )
}

// ============================================================================
// Header
// ============================================================================

export interface OnboardingHeaderProps
  extends React.ComponentPropsWithoutRef<"div"> {
  /** Step title (optional when using children) */
  title?: string
  /** Step description */
  description?: string
  /** Custom header content (overrides title/description) */
  children?: React.ReactNode
}

function OnboardingHeader({
  title,
  description,
  children,
  className,
  ...props
}: OnboardingHeaderProps) {
  if (children) {
    return (
      <div
        className={cn("text-center", className)}
        data-slot="onboarding-header"
        {...props}
      >
        {children}
      </div>
    )
  }

  return (
    <div
      className={cn(
        "flex flex-col gap-1 text-center",
        "[&_[data-slot=onboarding-title]]:font-normal [&_[data-slot=onboarding-title]]:font-serif [&_[data-slot=onboarding-title]]:text-3xl [&_[data-slot=onboarding-title]]:text-foreground",
        "[&_[data-slot=onboarding-description]]:text-base [&_[data-slot=onboarding-description]]:text-muted-foreground",
        className
      )}
      data-slot="onboarding-header"
      {...props}
    >
      {title != null && <h2 data-slot="onboarding-title">{title}</h2>}
      {description && <p data-slot="onboarding-description">{description}</p>}
    </div>
  )
}

// ============================================================================
// Navigation
// ============================================================================

export interface OnboardingNavigationProps
  extends React.ComponentPropsWithoutRef<"fieldset"> {
  /** Back button label */
  backLabel?: string
  /** Next button label */
  nextLabel?: string
  /** Complete button label */
  completeLabel?: string
  /** Override can go next (when not using Root's canGoNext) */
  canGoNext?: boolean
  /** Custom navigation content (use with asChild for full control) */
  children?: React.ReactNode
}

function OnboardingNavigation({
  backLabel = "Back",
  nextLabel = "Next",
  completeLabel = "Start Creating",
  canGoNext: canGoNextOverride,
  children,
  className,
  ...props
}: OnboardingNavigationProps) {
  const {
    currentStep,
    totalSteps,
    canGoNext: contextCanGoNext,
    canGoBack,
    handleBack,
    handleNext,
    handleComplete,
  } = useOnboarding()

  const canGoNext = canGoNextOverride ?? contextCanGoNext
  const isLastStep = currentStep === totalSteps

  if (children) {
    return (
      <fieldset
        className={cn("flex gap-3", className)}
        data-slot="onboarding-navigation"
        {...props}
      >
        {children}
      </fieldset>
    )
  }

  return (
    <fieldset
      aria-label="Onboarding navigation"
      className={cn("flex gap-3", className)}
      data-slot="onboarding-navigation"
      {...props}
    >
      <Button
        aria-label={backLabel}
        className="flex-1 rounded-xl py-5"
        data-slot="onboarding-back"
        disabled={!canGoBack}
        onClick={handleBack}
        variant="outline"
      >
        {backLabel}
      </Button>
      {isLastStep ? (
        <Button
          aria-label={completeLabel}
          className="flex-1 rounded-xl bg-foreground py-5 text-background hover:bg-foreground/90"
          data-slot="onboarding-complete"
          onClick={handleComplete}
        >
          {completeLabel}
        </Button>
      ) : (
        <Button
          aria-label={nextLabel}
          className="flex-1 rounded-xl bg-foreground py-5 text-background hover:bg-foreground/90"
          data-slot="onboarding-next"
          disabled={!canGoNext}
          onClick={handleNext}
        >
          {nextLabel}
        </Button>
      )}
    </fieldset>
  )
}

// ============================================================================
// Types
// ============================================================================

type Orientation = "horizontal" | "vertical" | "grid"

interface ChoiceGroupContextValue {
  value: string | null
  setValue: (value: string) => void
  name: string
  orientation: Orientation
}

// ============================================================================
// Context
// ============================================================================

const ChoiceGroupContext = createContext<ChoiceGroupContextValue | null>(null)

function useChoiceGroup() {
  const ctx = useContext(ChoiceGroupContext)
  if (!ctx) {
    throw new Error("ChoiceGroup.Item must be used within ChoiceGroup")
  }
  return ctx
}

// ============================================================================
// Root
// ============================================================================

export interface ChoiceGroupProps
  extends Omit<React.ComponentPropsWithoutRef<"div">, "defaultValue"> {
  /** Controlled selected value */
  value?: string | null
  /** Default selected value (uncontrolled) */
  defaultValue?: string | null
  /** Callback when selection changes */
  onValueChange?: (value: string) => void
  /** Name for radio group semantics (required for accessibility) */
  name: string
  /** Layout orientation */
  orientation?: Orientation
}

function ChoiceGroupRoot({
  value: controlledValue,
  defaultValue = null,
  onValueChange,
  name,
  orientation = "grid",
  children,
  className,
  ...props
}: ChoiceGroupProps) {
  const [value, setValueState] = useControllableState({
    prop: controlledValue ?? undefined,
    defaultProp: defaultValue ?? null,
    onChange: (v) => v !== null && onValueChange?.(v),
  })

  const setValue = useCallback(
    (v: string) => {
      setValueState(v)
    },
    [setValueState]
  )

  const contextValue = useMemo<ChoiceGroupContextValue>(
    () => ({
      value,
      setValue,
      name,
      orientation,
    }),
    [value, setValue, name, orientation]
  )

  return (
    <ChoiceGroupContext.Provider value={contextValue}>
      <div
        aria-label={name}
        className={cn(className)}
        data-orientation={orientation}
        data-slot="choice-group"
        role="radiogroup"
        {...props}
      >
        {children}
      </div>
    </ChoiceGroupContext.Provider>
  )
}

// ============================================================================
// Item
// ============================================================================

export interface ChoiceGroupItemProps
  extends React.ComponentPropsWithoutRef<"label"> {
  /** Value when this item is selected */
  value: string
}

function ChoiceGroupItemComponent({
  value: itemValue,
  children,
  className,
  ...props
}: ChoiceGroupItemProps) {
  const { value, setValue, name } = useChoiceGroup()
  const isSelected = value === itemValue

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      if (e.currentTarget.checked) {
        setValue(itemValue)
      }
    },
    [itemValue, setValue]
  )

  return (
    <label
      className={cn(className)}
      data-slot="choice-group-item"
      data-state={isSelected ? "selected" : "unselected"}
      {...props}
    >
      <input
        checked={isSelected}
        className="sr-only"
        name={name}
        onChange={handleChange}
        type="radio"
        value={itemValue}
      />
      {children}
    </label>
  )
}

ChoiceGroupItemComponent.displayName = "ChoiceGroupItem"

// ============================================================================
// Export
// ============================================================================

export const ChoiceGroup = Object.assign(ChoiceGroupRoot, {
  Item: ChoiceGroupItemComponent,
})

// ============================================================================
// Types
// ============================================================================

interface FeatureCarouselContextValue {
  value: number
  setValue: (value: number | ((prev: number) => number)) => void
  totalItems: number
  isActive: (index: number) => boolean
}

// ============================================================================
// Context
// ============================================================================

const FeatureCarouselContext =
  createContext<FeatureCarouselContextValue | null>(null)

function useFeatureCarousel() {
  const ctx = useContext(FeatureCarouselContext)
  if (!ctx) {
    throw new Error("FeatureCarousel.Item must be used within FeatureCarousel")
  }
  return ctx
}

// ============================================================================
// Root
// ============================================================================

export interface FeatureCarouselProps
  extends React.ComponentPropsWithoutRef<"div"> {
  /** Controlled active index */
  value?: number
  /** Default active index (uncontrolled) */
  defaultValue?: number
  /** Callback when active index changes */
  onValueChange?: (index: number) => void
  /** Total number of items (derived from children if not provided) */
  totalItems?: number
}

function FeatureCarouselRoot({
  value: controlledValue,
  defaultValue = 0,
  onValueChange,
  totalItems: totalItemsProp,
  children,
  className,
  ...props
}: FeatureCarouselProps) {
  const [value, setValue] = useControllableState({
    prop: controlledValue,
    defaultProp: defaultValue,
    onChange: onValueChange,
  })

  const totalItems = totalItemsProp ?? Children.count(children)

  const isActive = useCallback((index: number) => value === index, [value])

  const contextValue = useMemo<FeatureCarouselContextValue>(
    () => ({
      value,
      setValue,
      totalItems,
      isActive,
    }),
    [value, setValue, totalItems, isActive]
  )

  return (
    <FeatureCarouselContext.Provider value={contextValue}>
      <div
        aria-label="Features"
        className={cn(className)}
        data-slot="feature-carousel"
        role="tablist"
        {...props}
      >
        {children}
      </div>
    </FeatureCarouselContext.Provider>
  )
}

// ============================================================================
// Item
// ============================================================================

export interface FeatureCarouselItemProps
  extends React.ComponentPropsWithoutRef<"button"> {
  /** Index of this item (0-based) */
  index: number
}

function FeatureCarouselItemComponent({
  index,
  children,
  className,
  onClick,
  ...props
}: FeatureCarouselItemProps) {
  const { setValue, isActive, totalItems } = useFeatureCarousel()
  const active = isActive(index)

  const handleClick = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      setValue(index)
      onClick?.(e)
    },
    [index, setValue, onClick]
  )

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLButtonElement>) => {
      if (totalItems <= 1) {
        return
      }
      if (e.key === "ArrowRight" || e.key === "ArrowDown") {
        e.preventDefault()
        setValue((prev) => Math.min(prev + 1, totalItems - 1))
      } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
        e.preventDefault()
        setValue((prev) => Math.max(prev - 1, 0))
      }
    },
    [totalItems, setValue]
  )

  return (
    <button
      aria-selected={active}
      className={cn(className)}
      data-slot="feature-carousel-item"
      data-state={active ? "active" : "inactive"}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      role="tab"
      tabIndex={active ? 0 : -1}
      type="button"
      {...props}
    >
      {children}
    </button>
  )
}

FeatureCarouselItemComponent.displayName = "FeatureCarouselItem"

// ============================================================================
// Export
// ============================================================================

export const FeatureCarousel = Object.assign(FeatureCarouselRoot, {
  Item: FeatureCarouselItemComponent,
})

// ============================================================================
// TipsList
// ============================================================================

export interface TipsListProps extends React.ComponentPropsWithoutRef<"div"> {
  /** Optional title/label for the list */
  title?: string
}

/**
 * Headless tips list primitive.
 * Renders an ordered list with optional title.
 * No visual styling—consumer provides via className.
 */
function TipsListRoot({ title, children, className, ...props }: TipsListProps) {
  const titleId = useId()
  return (
    <div className={cn(className)} data-slot="tips-list" {...props}>
      {title && (
        <p className="sr-only" data-slot="tips-list-title" id={titleId}>
          {title}
        </p>
      )}
      <ol
        aria-label={title ? undefined : "Tips"}
        aria-labelledby={title ? titleId : undefined}
        data-slot="tips-list-items"
      >
        {children}
      </ol>
    </div>
  )
}

// ============================================================================
// Item
// ============================================================================

export interface TipsListItemProps
  extends React.ComponentPropsWithoutRef<"li"> {
  /** Optional number to display (for custom styling) */
  number?: number
}

function TipsListItemComponent({
  number,
  children,
  className,
  ...props
}: TipsListItemProps) {
  return (
    <li
      className={cn(className)}
      data-number={number}
      data-slot="tips-list-item"
      {...props}
    >
      {number != null && (
        <span aria-hidden data-slot="tips-list-item-number">
          {number}
        </span>
      )}
      {children}
    </li>
  )
}

// ============================================================================
// Export
// ============================================================================

export const TipsList = Object.assign(TipsListRoot, {
  Item: TipsListItemComponent,
})

// ============================================================================
// Export
// ============================================================================

export const Onboarding = Object.assign(OnboardingRoot, {
  Step: OnboardingStep,
  StepIndicator: OnboardingStepIndicator,
  Header: OnboardingHeader,
  Navigation: OnboardingNavigation,
})

export { useOnboarding }

Dependencies

@radix-ui/react-use-controllable-stateclass-variance-authority

Registry dependencies

button

Source: Cult UI