Onboarding component
Install
npx shadcn add https://ui.happenings.social/r/onboarding.json
Or with namespace
npx shadcn add @happenings/onboarding
Preview
iOS-style paged onboarding flow. Supports custom bottom builders, page indicators, and feature pages with icons.
Registry dependencies
src/components/ui/onboarding.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Onboarding – iOS-style paged onboarding for the web */
/* Modelled after cupertino_onboarding, adapted for shadcn/Zinc. */
/* -------------------------------------------------------------------------- */
/* ── Context ─────────────────────────────────────────────────────────────── */
interface OnboardingContextValue {
currentPage: number;
totalPages: number;
goToPage: (page: number) => void;
goToNext: () => void;
}
const OnboardingContext = React.createContext<OnboardingContextValue>({
currentPage: 0,
totalPages: 0,
goToPage: () => {},
goToNext: () => {},
});
function useOnboarding() {
return React.useContext(OnboardingContext);
}
/* ── Onboarding (root) ───────────────────────────────────────────────────── */
interface OnboardingProps {
children: React.ReactNode[];
className?: string | undefined;
onPageChange?: ((page: number) => void) | undefined;
/** Render prop for the bottom area – receives current page and goToNext. */
bottomBuilder?: ((
currentPage: number,
goToNext: () => void,
) => React.ReactNode) | undefined;
/** Simple mode: fixed button label shown on all pages. */
bottomButtonLabel?: string | undefined;
/** Simple mode: called on the last page's button press. */
onPressedOnLastPage?: (() => void) | undefined;
/** Simple mode: called on non-last pages' button press. Falls back to goToNext. */
onPressed?: (() => void) | undefined;
/** Widget rendered above the default bottom button. */
widgetAboveBottomButton?: React.ReactNode | undefined;
}
function Onboarding({
children,
className,
onPageChange,
bottomBuilder,
bottomButtonLabel = "Continue",
onPressedOnLastPage,
onPressed,
widgetAboveBottomButton,
}: OnboardingProps) {
const [currentPage, setCurrentPage] = React.useState(0);
const pages = React.Children.toArray(children);
const totalPages = pages.length;
const goToPage = React.useCallback(
(page: number) => {
const next = Math.max(0, Math.min(page, totalPages - 1));
setCurrentPage(next);
onPageChange?.(next);
},
[totalPages, onPageChange],
);
const goToNext = React.useCallback(() => {
goToPage(currentPage + 1);
}, [currentPage, goToPage]);
const contextValue = React.useMemo<OnboardingContextValue>(
() => ({ currentPage, totalPages, goToPage, goToNext }),
[currentPage, totalPages, goToPage, goToNext],
);
return (
<OnboardingContext.Provider value={contextValue}>
<div
className={cn(
"flex min-h-dvh flex-col bg-zinc-50 dark:bg-zinc-950",
className,
)}
>
{/* Page content */}
<div className="relative flex flex-1 overflow-hidden">
{pages.map((page, i) => (
<div
key={i}
className={cn(
"absolute inset-0 flex flex-col transition-all duration-500 ease-in-out",
i === currentPage
? "translate-x-0 opacity-100"
: i < currentPage
? "-translate-x-full opacity-0"
: "translate-x-full opacity-0",
)}
aria-hidden={i !== currentPage}
>
{page}
</div>
))}
</div>
{/* Dots */}
{totalPages > 1 && (
<OnboardingDots className="justify-center py-4" />
)}
{/* Bottom area */}
{bottomBuilder ? (
bottomBuilder(currentPage, goToNext)
) : (
<div className="px-6 pb-16 sm:mx-auto sm:w-full sm:max-w-md">
{widgetAboveBottomButton ?? <div className="h-4" />}
<button
type="button"
onClick={
currentPage === totalPages - 1
? onPressedOnLastPage
: (onPressed ?? goToNext)
}
className="flex h-12 w-full items-center justify-center rounded-xl bg-zinc-900 text-sm font-semibold text-zinc-50 transition-all active:scale-[0.97] hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
{bottomButtonLabel}
</button>
</div>
)}
</div>
</OnboardingContext.Provider>
);
}
/* ── Dots ─────────────────────────────────────────────────────────────────── */
interface OnboardingDotsProps {
className?: string | undefined;
}
function OnboardingDots({ className }: OnboardingDotsProps) {
const { currentPage, totalPages } = useOnboarding();
return (
<div className={cn("flex gap-2", className)}>
{Array.from({ length: totalPages }, (_, i) => (
<div
key={i}
className={cn(
"size-2 rounded-full transition-colors duration-300",
i === currentPage
? "bg-zinc-600 dark:bg-zinc-300"
: "bg-zinc-300 dark:bg-zinc-700",
)}
/>
))}
</div>
);
}
/* ── OnboardingPage ──────────────────────────────────────────────────────── */
interface OnboardingPageProps {
title: React.ReactNode;
children: React.ReactNode;
titleClassName?: string | undefined;
bodyClassName?: string | undefined;
className?: string | undefined;
}
function OnboardingPage({
title,
children,
titleClassName,
bodyClassName,
className,
}: OnboardingPageProps) {
return (
<div className={cn("flex flex-1 flex-col", className)}>
<div
className={cn(
"px-6 pt-20 pb-10 text-center sm:pt-24",
titleClassName,
)}
>
<div className="mx-auto max-w-sm">
<div className="text-[clamp(1.75rem,5vw,2.2rem)] leading-tight font-bold tracking-tight text-zinc-900 dark:text-zinc-100">
{title}
</div>
</div>
</div>
<div
className={cn(
"flex flex-1 flex-col px-6 sm:px-10",
bodyClassName,
)}
>
{children}
</div>
</div>
);
}
/* ── OnboardingFeaturePage ───────────────────────────────────────────────── */
interface OnboardingFeaturePageProps {
title: React.ReactNode;
features: React.ReactNode[];
className?: string | undefined;
}
function OnboardingFeaturePage({
title,
features,
className,
}: OnboardingFeaturePageProps) {
return (
<OnboardingPage title={title} className={className}>
<div className="mx-auto flex w-full max-w-md flex-col gap-6">
{features}
</div>
</OnboardingPage>
);
}
/* ── OnboardingFeature ───────────────────────────────────────────────────── */
interface OnboardingFeatureProps {
icon: React.ReactNode;
title: React.ReactNode;
description: React.ReactNode;
iconClassName?: string | undefined;
}
function OnboardingFeature({
icon,
title,
description,
iconClassName,
}: OnboardingFeatureProps) {
return (
<div className="flex gap-4">
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center text-zinc-900 dark:text-zinc-100",
iconClassName,
)}
>
{icon}
</div>
<div className="min-w-0 space-y-0.5">
<div className="text-[15px] font-semibold text-zinc-900 dark:text-zinc-100">
{title}
</div>
<div className="text-[15px] leading-relaxed text-zinc-500 dark:text-zinc-400">
{description}
</div>
</div>
</div>
);
}
/* ── Exports ─────────────────────────────────────────────────────────────── */
export {
Onboarding,
OnboardingPage,
OnboardingFeaturePage,
OnboardingFeature,
OnboardingDots,
useOnboarding,
};
export type { OnboardingProps, OnboardingPageProps, OnboardingFeaturePageProps, OnboardingFeatureProps };