Slideshow

Step-through slideshow for presenting content sequentially. Install Slideshow from the VLLNT UI registry with the shadcn CLI.

Report a bug

Step-through slideshow for presenting content sequentially. Part of the content family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

Add Slideshow to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/slideshow.json

Source

"use client"; import { memo, useCallback, useEffect, useEffectEvent, useState } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; import type { HeadingTag } from "../../lib/types"; import { useBodyScrollLock } from "../../lib/use-body-scroll-lock"; import { useMounted } from "../../lib/use-mounted"; import { cn } from "../../lib/utils"; import { CompletionDialog } from "../completion-dialog/completion-dialog"; export type SlideshowSection = { id: string; title: string; }; export type SlideshowLabels = { closeLabel?: string; closeTocLabel?: string; exitLabel?: string; finishLabel?: string; nextLabel?: string; openTocLabel?: string; prevLabel?: string; sectionsLabel?: string; }; export type SlideshowProps = { /** Heading tag for the sections label inside the table of contents. Defaults to `h3`. */ as?: HeadingTag; /** Completed section IDs */ completedSections: Set<string>; /** Dialog labels */ completionDialogTitle?: string; /** Current section index */ currentIndex: number; /** Labels for i18n */ labels?: SlideshowLabels; /** Callback when tutorial completes */ onComplete: () => void; /** Callback to exit slideshow */ onExit: () => void; /** Callback to navigate to section */ onNavigate: (index: number) => void; /** Callback to toggle section completion */ onToggleSection: (sectionId: string) => void; /** Render function for section content */ renderContent: (section: SlideshowSection) => ReactNode; /** Sections to display */ sections: SlideshowSection[]; /** Tutorial title */ title: string; }; const DEFAULT_LABELS: Required<SlideshowLabels> = { closeLabel: "Close", closeTocLabel: "Close table of contents", exitLabel: "Exit", finishLabel: "Finish", nextLabel: "Next", openTocLabel: "Open table of contents", prevLabel: "Prev", sectionsLabel: "Sections", }; const EMPTY_SLIDESHOW_LABELS: SlideshowLabels = {}; function SlideshowProgress({ progress }: { progress: number }) { return ( <div className="absolute top-0 left-0 right-0 h-1 bg-muted z-10"> <div className="h-full bg-foreground transition-all duration-300 ease-out" style={{ width: `${progress}%` }} /> </div> ); } function SlideshowHeader({ currentIndex, isTocOpen, labels, onExit, onToggleToc, sectionCount, sectionTitle, title, }: { currentIndex: number; isTocOpen: boolean; labels: Required<SlideshowLabels>; onExit: () => void; onToggleToc: () => void; sectionCount: number; sectionTitle: string; title: string; }) { return ( <div className="flex items-center justify-between px-4 py-3 mt-1 border-b border-border bg-background"> <div className="flex items-center gap-3 min-w-0 flex-1"> <button aria-label={isTocOpen ? labels.closeTocLabel : labels.openTocLabel} className="flex-shrink-0 p-2 rounded-lg hover:bg-muted transition-colors" onClick={onToggleToc} type="button" > {isTocOpen ? ( <svg className="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M6 18L18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> ) : ( <svg className="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M4 6h16M4 12h16M4 18h16" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> )} </button> <div className="min-w-0 flex-1"> <p className="text-xs text-muted-foreground truncate">{title}</p> <p className="text-sm font-medium truncate">{sectionTitle}</p> </div> </div> <div className="flex items-center gap-2 flex-shrink-0"> <span className="text-xs text-muted-foreground tabular-nums hidden sm:inline"> {currentIndex + 1}/{sectionCount} </span> <button aria-label={labels.exitLabel} className="p-2 rounded-lg hover:bg-muted transition-colors" onClick={onExit} type="button" > <svg className="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M6 18L18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> </button> </div> </div> ); } function SlideshowToc({ as: SectionsHeading = "h3", completedSections, currentIndex, isOpen, labels, onClose, onNavigate, sections, }: { as?: HeadingTag; completedSections: Set<string>; currentIndex: number; isOpen: boolean; labels: Required<SlideshowLabels>; onClose: () => void; onNavigate: (index: number) => void; sections: SlideshowSection[]; }) { if (!isOpen) return null; return ( <div className="absolute inset-0 z-20 flex animate-in fade-in-0 duration-200" onClick={onClose} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") onClose(); }} role="button" tabIndex={0} > <div className="absolute inset-0 bg-background/40" /> {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} <div className="relative w-full sm:max-w-sm bg-background border-r border-border h-full overflow-auto shadow-2xl" onClick={(event) => { event.stopPropagation(); }} onKeyDown={(event) => { event.stopPropagation(); }} role="dialog" > <div className="sticky top-0 flex items-center justify-between px-4 py-3 border-b border-border bg-background"> <SectionsHeading className="font-semibold"> {labels.sectionsLabel} </SectionsHeading> <button aria-label={labels.closeLabel} className="p-2 rounded-lg hover:bg-muted transition-colors" onClick={onClose} type="button" > <svg className="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M6 18L18 6M6 6l12 12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> </button> </div> <div className="p-2"> {sections.map((section, index) => { const isCompleted = completedSections.has(section.id); const isCurrent = index === currentIndex; return ( <button className={cn( "w-full flex items-center gap-3 p-3 rounded-lg text-left transition-colors", isCurrent ? "bg-muted" : "hover:bg-muted/50", )} key={section.id} onClick={() => { onNavigate(index); }} type="button" > <div className={cn( "flex-shrink-0 size-5 rounded-full border-2 flex items-center justify-center", isCompleted ? "bg-foreground border-foreground" : "border-muted-foreground", )} > {isCompleted ? ( <svg className="size-3 text-background" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> ) : null} </div> <span className={cn( "flex-1 text-sm truncate", isCompleted && "line-through opacity-60", )} > {section.title} </span> </button> ); })} </div> </div> </div> ); } function SlideshowContent({ section, source, }: { section: SlideshowSection; source: (section: SlideshowSection) => ReactNode; }): ReactNode { return source(section); } function SlideshowNav({ canGoPrevious, isLastSection, labels, onNext, onPrevious, }: { canGoPrevious: boolean; isLastSection: boolean; labels: Required<SlideshowLabels>; onNext: () => void; onPrevious: () => void; }) { return ( <div className="relative z-20 flex items-center justify-between p-4 border-t border-border bg-background"> <button className="min-w-[100px] gap-1 inline-flex items-center justify-center px-4 py-2 rounded-md hover:bg-muted transition-colors disabled:opacity-50 disabled:pointer-events-none" disabled={!canGoPrevious} onClick={onPrevious} type="button" > <svg className="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="m15 19-7-7 7-7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> <span>{labels.prevLabel}</span> </button> <button className="min-w-[100px] gap-1 inline-flex items-center justify-center px-4 py-2 rounded-md bg-foreground text-background hover:bg-foreground/90 transition-colors" onClick={onNext} type="button" > <span>{isLastSection ? labels.finishLabel : labels.nextLabel}</span> {!isLastSection && ( <svg className="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path d="m9 5 7 7-7 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} /> </svg> )} </button> </div> ); } function SlideshowImpl({ as: SectionsHeading = "h3", completedSections, completionDialogTitle = "Mark section as complete?", currentIndex, labels = EMPTY_SLIDESHOW_LABELS, onComplete, onExit, onNavigate, onToggleSection, renderContent, sections, title, }: SlideshowProps): React.ReactNode { const mergedLabels = { ...DEFAULT_LABELS, ...labels }; const [animationDirection, setAnimationDirection] = useState< "left" | "right" | null >(null); const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false); const [isTocOpen, setIsTocOpen] = useState(false); const mounted = useMounted(); const currentSection = sections[currentIndex]; const isCurrentCompleted = currentSection ? completedSections.has(currentSection.id) : false; const isLastSection = currentIndex === sections.length - 1; const canGoNext = currentIndex < sections.length - 1; const canGoPrevious = currentIndex > 0; const progress = ((currentIndex + 1) / sections.length) * 100; useBodyScrollLock(true); const goToSection = useCallback( (index: number, direction: "left" | "right") => { setAnimationDirection(direction); setTimeout(() => { onNavigate(index); setAnimationDirection(null); }, 150); }, [onNavigate], ); const handlePrevious = useCallback(() => { if (canGoPrevious) goToSection(currentIndex - 1, "right"); }, [canGoPrevious, currentIndex, goToSection]); const handleNext = useCallback(() => { if (!canGoNext) { if (isCurrentCompleted) onComplete(); else setIsCompletionDialogOpen(true); return; } if (isCurrentCompleted) goToSection(currentIndex + 1, "left"); else setIsCompletionDialogOpen(true); }, [canGoNext, currentIndex, goToSection, isCurrentCompleted, onComplete]); const handleMarkComplete = useCallback(() => { if (currentSection) onToggleSection(currentSection.id); setIsCompletionDialogOpen(false); if (isLastSection) onComplete(); else goToSection(currentIndex + 1, "left"); }, [ currentSection, onToggleSection, isLastSection, onComplete, goToSection, currentIndex, ]); const handleSkip = useCallback(() => { setIsCompletionDialogOpen(false); if (isLastSection) onComplete(); else goToSection(currentIndex + 1, "left"); }, [isLastSection, onComplete, goToSection, currentIndex]); const handleTocNavigate = useCallback( (index: number) => { setIsTocOpen(false); goToSection(index, index > currentIndex ? "left" : "right"); }, [currentIndex, goToSection], ); const onKeyDown = useEffectEvent((event: KeyboardEvent) => { if (isCompletionDialogOpen) return; if (event.key === "Escape") { event.preventDefault(); if (isTocOpen) setIsTocOpen(false); else onExit(); return; } if (event.key === "t" || event.key === "T") { event.preventDefault(); setIsTocOpen((p) => !p); return; } if (event.key === "ArrowRight" || event.key === "j") { event.preventDefault(); handleNext(); return; } if (event.key === "ArrowLeft" || event.key === "k") { event.preventDefault(); handlePrevious(); } }); useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { onKeyDown(event); }; document.addEventListener("keydown", handleKeyDown, true); return () => { document.removeEventListener("keydown", handleKeyDown, true); }; }, []); if (!currentSection || !mounted) return null; return createPortal( <div className="fixed inset-0 z-[9999] bg-background flex flex-col"> <SlideshowProgress progress={progress} /> <SlideshowHeader currentIndex={currentIndex} isTocOpen={isTocOpen} labels={mergedLabels} onExit={onExit} onToggleToc={() => { setIsTocOpen((p) => !p); }} sectionCount={sections.length} sectionTitle={currentSection.title} title={title} /> <div className="relative flex-1 overflow-hidden"> <SlideshowToc as={SectionsHeading} completedSections={completedSections} currentIndex={currentIndex} isOpen={isTocOpen} labels={mergedLabels} onClose={() => { setIsTocOpen(false); }} onNavigate={handleTocNavigate} sections={sections} /> <div className="h-full overflow-auto px-4 py-8 md:px-8 lg:px-16"> <div className="mx-auto max-w-3xl"> <div className={cn( "transition-all duration-150 ease-out", animationDirection === "left" && "opacity-0 -translate-x-4", animationDirection === "right" && "opacity-0 translate-x-4", !animationDirection && "opacity-100 translate-x-0", )} > <SlideshowContent section={currentSection} source={renderContent} /> </div> </div> </div> </div> <SlideshowNav canGoPrevious={canGoPrevious} isLastSection={isLastSection} labels={mergedLabels} onNext={handleNext} onPrevious={handlePrevious} /> <CompletionDialog description={`You're about to ${isLastSection ? "finish" : "move to the next section from"}: ${currentSection.title}`} isOpen={isCompletionDialogOpen} onCancel={handleSkip} onClose={() => { setIsCompletionDialogOpen(false); }} onConfirm={handleMarkComplete} title={completionDialogTitle} /> </div>, document.body, ); } export const Slideshow = memo(SlideshowImpl); Slideshow.displayName = "Slideshow";

Stories

Explore every variant and state in the interactive Storybook:

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Dependencies

  • @vllnt/ui@^0.3.0