Slideshow
Step-through slideshow for presenting content sequentially.
A slideshow with keyboard navigation and progress.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.com/r/slideshow.jsonbash
Code
"use client";
import { memo, useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { useMounted } from "../../lib/use-mounted";
import { cn } from "../../lib/utils";
import { CompletionDialog } from "../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 = {
/** 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",
};
function SlideshowImpl({
completedSections,
completionDialogTitle = "Mark section as complete?",
currentIndex,
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;
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
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],
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
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();
}
};
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [handleNext, handlePrevious, onExit, isTocOpen, isCompletionDialogOpen]);
if (!currentSection || !mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] bg-background flex flex-col">
{/* Progress Bar */}
<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>
{/* Header */}
<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 ? mergedLabels.closeTocLabel : mergedLabels.openTocLabel
}
className="flex-shrink-0 p-2 rounded-lg hover:bg-muted transition-colors"
onClick={() => {
setIsTocOpen((p) => !p);
}}
type="button"
>
{isTocOpen ? (
<svg
className="h-5 w-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="h-5 w-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">
{currentSection.title}
</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}/{sections.length}
</span>
<button
aria-label={mergedLabels.exitLabel}
className="p-2 rounded-lg hover:bg-muted transition-colors"
onClick={onExit}
type="button"
>
<svg
className="h-5 w-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>
{/* Content */}
<div className="relative flex-1 overflow-hidden">
{isTocOpen ? (
<div
className="absolute inset-0 z-20 flex animate-in fade-in-0 duration-200"
onClick={() => {
setIsTocOpen(false);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ")
setIsTocOpen(false);
}}
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">
<h3 className="font-semibold">{mergedLabels.sectionsLabel}</h3>
<button
aria-label={mergedLabels.closeLabel}
className="p-2 rounded-lg hover:bg-muted transition-colors"
onClick={() => {
setIsTocOpen(false);
}}
type="button"
>
<svg
className="h-4 w-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}-${index}`}
onClick={() => {
handleTocNavigate(index);
}}
type="button"
>
<div
className={cn(
"flex-shrink-0 w-5 h-5 rounded-full border-2 flex items-center justify-center",
isCompleted
? "bg-foreground border-foreground"
: "border-muted-foreground",
)}
>
{isCompleted ? (
<svg
className="h-3 w-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>
) : null}
<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",
)}
>
{renderContent(currentSection)}
</div>
</div>
</div>
</div>
{/* Bottom Nav */}
<div className="relative z-20 flex items-center justify-between px-4 py-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={handlePrevious}
type="button"
>
<svg
className="h-4 w-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>{mergedLabels.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={handleNext}
type="button"
>
<span>
{isLastSection ? mergedLabels.finishLabel : mergedLabels.nextLabel}
</span>
{!isLastSection && (
<svg
className="h-4 w-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>
<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";
typescript