All components
Onboarding
navigationComposable 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.jsonUsage
"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 <component></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