shadimage
slide-cn
componentslidesource: ok · hit

Slide

Basic slide component

npx shadcn add "https://slide-cn.com/r/slide.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files7 · source cached 10/14/2026, 2:12:02 PM

components/ui/slide-cn/deck.tsx
"use client";
import { useDeckController } from "@/components/ui/slide-cn/use-deck-controller";
import { useKeyboardNavigation } from "@/components/ui/slide-cn/use-keyboard-navigation";
import { NavigationToast } from "@/components/ui/slide-cn/navigation-toast";
import React from "react";
import { AnimatePresence } from "motion/react";
import { SlideNav } from "@/components/ui/slide-cn/slide-nav";
import { cn } from "@/lib/utils";



/**
 * Deck
 *
 * Root container for a slide presentation.
 *
 * Responsibilities:
 * - Owns viewport height (`h-screen`)
 * - Manages active slide index
 * - Handles global navigation (keyboard, nav UI)
 * - Provides deck state via context
 *
 * Non-responsibilities:
 * - Does NOT manage slide layout or content
 * - Does NOT manage per-slide scroll behavior
 * - Does NOT impose styling on slides
 *
 * Usage:
 * ```tsx
 * <Deck>
 *   <Slide>...</Slide>
 *   <Slide>...</Slide>
 * </Deck>
 * ```
 *
 * Notes:
 * - Deck must be mounted in a client component
 * - Slides are rendered one at a time via AnimatePresence
 * - Height is fixed to the viewport; do not nest Deck inside another Deck
 */



const DeckContext = React.createContext<ReturnType<
	typeof useDeckController
> | null>(null);

type NavigationToastProps = {
	duration?: number;
	desktopMessage?: string;
	mobileMessage?: string;
	className?: string;
};

type DeckProps = {
	children: React.ReactNode;
	className?: string;
	showNavigationToast?: boolean;
	navigationToastProps?: NavigationToastProps;
};

export function Deck({ children, className, showNavigationToast = true, navigationToastProps }: DeckProps) {
	const slides = React.Children.toArray(children);
	const deck = useDeckController(slides.length);
	useKeyboardNavigation({
		onNext: deck.next,
		onPrev: deck.prev
	});


	return (
		<DeckContext.Provider
			value={deck}
		>
			<div className={cn("absolute inset-0 overflow-hidden touch-pan-y md:touch-none h-screen", className)}>

				<SlideNav />
				<div className="absolute inset-0 overflow-y-auto md:overflow-hidden h-full">
					<AnimatePresence mode="wait">
						{slides[deck.index]}
					</AnimatePresence>
				</div>
				{showNavigationToast && <NavigationToast {...navigationToastProps} />}

			</div>

		</DeckContext.Provider>
	);
}

export function useDeck() {
	const ctx = React.useContext(DeckContext);
	if (!ctx) throw new Error("useDeck must be used inside Deck");
	return ctx;
}

components/ui/slide-cn/slide.tsx
"use client";
import React from "react";
import { motion } from "motion/react";
import { useDeck } from "@/components/ui/slide-cn/deck";
import { SlideFooter } from "@/components/ui/slide-cn/slide-footer";


/**
 * Slide
 *
 * Main container for each slide.
 *
 * Responsibilities:
 * - Handles the transition animation between slides
 * - Handles swipe navigation on mobile
 * - Renders a background for the slide if provided, and a footer by default
 *
 * Non Responsibilities:
 * - Does not handle slide layout
 * 
 * Usage:
 * ```tsx
 * <Deck>
 * 	<Slide background={<BackgroundComponent/>}>
 * 		{slide content}
 * 	</Slide>
 * </Deck>
 * ```
 */


type SlideProps = {
	children: React.ReactNode;
	background?: React.ReactNode;
	footer?: React.ReactNode;
};

export function Slide({ children, background, footer = <SlideFooter /> }: SlideProps) {
	const deck = useDeck();

	return (
		<motion.div
			data-slide
			className="
        relative w-full h-full
        overflow-hidden
        md:absolute md:inset-0
      "
			exit={{ opacity: 0 }}

		>
			{/* Background layer (viewport-scoped, never scrolls) */}
			{background && (
				<div className="absolute inset-0 pointer-events-none">
					{background}
				</div>
			)}

			{/* Scroll layer (mobile scroll lives here) */}
			<motion.div className="relative z-10 h-full w-full overflow-y-auto md:overflow-hidden"
				drag="x"
				dragConstraints={{ left: 0, right: 0 }}
				dragElastic={0.25}
				dragMomentum={false}
				whileDrag={{ scale: 1 }}
				onDragEnd={(_, info) => {
					const swipeDistance = info.offset.x;
					const swipeVelocity = info.velocity.x;

					const DISTANCE_THRESHOLD = 60;
					const VELOCITY_THRESHOLD = 600;

					if (Math.abs(info.offset.y) > Math.abs(info.offset.x)) return;

					if (
						swipeDistance > DISTANCE_THRESHOLD ||
						swipeVelocity > VELOCITY_THRESHOLD
					) {
						deck.prev();
					} else if (
						swipeDistance < -DISTANCE_THRESHOLD ||
						swipeVelocity < -DISTANCE_THRESHOLD
					) {
						deck.next();
					}
				}}
			>
				<div className="h-full w-full flex flex-col">
					<div className="flex-1">
						{children}
					</div>

					{footer && (
						<div className="mt-4">
							{footer}
						</div>
					)}
				</div>
			</motion.div>
		</motion.div>
	);
}
components/ui/slide-cn/slide-nav.tsx
"use client";
import { Button } from "@/components/ui/button";
import { useDeck } from "@/components/ui/slide-cn/deck";

/**
 * SlideNav
 *
 * SlideNav is already included in the deck component by default. You dont need to touch this component unless you want to modify how changing slides works
 */

export function SlideNav() {
	const deck = useDeck();

	return (
		<>
			{/* LEFT ZONE */}
			<div className="hidden md:block pointer-events-none absolute inset-y-0 left-0 w-24">
				<div className="group pointer-events-auto h-full w-full flex items-center">
					<Button
						type="button"
						onClick={deck.prev}
						className="
							ml-4
							opacity-0
							transition-opacity
							group-hover:opacity-100"
					>
						Prev
					</Button>
				</div>
			</div>

			{/* RIGHT ZONE */}
			<div className="hidden md:block pointer-events-none absolute inset-y-0 right-0 w-24">
				<div className="group pointer-events-auto h-full w-full flex items-center justify-end">
					<Button
						type="button"
						onClick={deck.next}
						className="
              mr-4
              opacity-0
              transition-opacity
              group-hover:opacity-100
            "
					>
						Next
					</Button>
				</div>
			</div>
		</>
	);
}
components/ui/slide-cn/slide-footer.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { useDeck } from "@/components/ui/slide-cn/deck";
import { ChevronLeft, ChevronRight } from "lucide-react";

function FooterKeyCap({ children }: { children: React.ReactNode }) {
	return (
		<span className="inline-flex items-center justify-center w-5 h-5 rounded bg-foreground/10 border border-foreground/15 text-[10px] font-mono leading-none">
			{children}
		</span>
	);
}

/**
 * SlideFooter
 *
 * Optional footer component for slides that provides contextual metadata
 * and navigation affordances without affecting slide layout.
 *
 * Responsibilities:
 * - Display slide progress (current / total)
 * - Show navigation hints appropriate to device (keyboard vs swipe)
 * - Optionally render lightweight branding attribution
 *
 * Interaction model:
 * - Purely informational (no navigation control)
 * - Adapts copy and hints for mobile vs desktop
 *
 * Usage:
 * ```tsx
 * <Slide footer={<SlideFooter />} />
 *
 * <Slide
 *   footer={<SlideFooter showAdd showHint={false} />}
 * />
 * ```
 *
 * Notes:
 * - Footer is part of content flow (non-sticky)
 * - Does not control slide navigation or state
 * - Intended to remain visually subtle and non-distracting
 */

type SlideFooterProps = {
	className?: string;
	showProgress?: boolean;
	showHint?: boolean;
	showAdd?: boolean;
};

export function SlideFooter({
	className,
	showProgress = true,
	showHint = true,
	showAdd = false,
}: SlideFooterProps) {
	const deck = useDeck();

	// Using grid layout for precise alignment: Left (Progress), Center (Branding), Right (Hint)
	return (
		<footer
			className={cn(
				"grid grid-cols-3 items-center gap-4 px-6 py-4 w-full",
				"bg-background/20 backdrop-blur-lg",
				"text-sm font-medium text-muted-foreground transition-colors",
				className
			)}
		>
			{/* Left: Progress */}
			<div className="flex justify-start">
				{showProgress && (
					<span className="tabular-nums opacity-75 hover:opacity-100 transition-opacity">
						Slide {deck.index + 1} <span className="text-muted-foreground/40 mx-1">/</span> {deck.total}
					</span>
				)}
			</div>

			{/* Center: Branding (Optional) */}
			<div className="flex justify-center">
				{showAdd && (
					<span className="opacity-50 hover:opacity-100 transition-opacity flex items-center gap-1.5 text-xs">
						Built with <a href={"https://slide-cn.com"} target="_blank" className="font-semibold hover:underline">Slide-CN</a>
					</span>
				)}
			</div>

			{/* Right: Hint */}
			<div className="flex justify-end">
				{showHint && (
					<span className="hidden sm:flex items-center gap-2 opacity-70 hover:opacity-100 transition-opacity">
						<FooterKeyCap>&#8592;</FooterKeyCap>
						<FooterKeyCap>&#8594;</FooterKeyCap>
					</span>
				)}
				{/* Mobile simplified hint */}
				{showHint && (
					<span className="sm:hidden flex items-center gap-1.5 opacity-70 hover:opacity-100 transition-opacity text-xs">
						<span>Swipe</span>
						<ChevronLeft className="w-3 h-3" />
						<ChevronRight className="w-3 h-3" />
					</span>
				)}
			</div>
		</footer>
	);
}
components/ui/slide-cn/navigation-toast.tsx
"use client";

import * as React from "react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
import { useDeck } from "@/components/ui/slide-cn/deck";
import { useIsMobile } from "@/hooks/use-mobile";

type NavigationToastProps = {
	duration?: number;
	desktopMessage?: string;
	mobileMessage?: string;
	className?: string;
};

export function NavigationToast({
	duration = 3000,
	desktopMessage = "Use arrow keys to navigate",
	mobileMessage = "Swipe left or right to navigate",
	className,
}: NavigationToastProps) {
	const [visible, setVisible] = React.useState(true);
	const isMobile = useIsMobile();
	const deck = useDeck();
	const initialIndex = React.useRef(deck.index);

	// Dismiss on navigation
	React.useEffect(() => {
		if (deck.index !== initialIndex.current) {
			setVisible(false);
		}
	}, [deck.index]);

	// Auto-dismiss after duration
	React.useEffect(() => {
		const timer = setTimeout(() => setVisible(false), duration);
		return () => clearTimeout(timer);
	}, [duration]);

	return (
		<AnimatePresence>
			{visible && (
				<motion.div
					initial={{ opacity: 0, y: 20 }}
					animate={{ opacity: 1, y: 0 }}
					exit={{ opacity: 0, y: 10 }}
					transition={{ duration: 0.3, ease: "easeOut" }}
					onClick={() => setVisible(false)}
					className={cn(
						"cursor-pointer",
						"absolute bottom-20 left-1/2 -translate-x-1/2 z-50",
						"flex items-center gap-3 px-5 py-3 rounded-full",
						"bg-foreground/10 backdrop-blur-xl border border-foreground/10",
						"text-sm font-medium text-foreground/80",
						"shadow-lg",
						className
					)}
				>
					{isMobile ? (
						<>
							<SwipeIcon />
							<span>{mobileMessage}</span>
						</>
					) : (
						<>
							<span>{desktopMessage}</span>
							<div className="flex items-center gap-1.5">
								<KeyCap>&#8592;</KeyCap>
								<KeyCap>&#8594;</KeyCap>
							</div>
						</>
					)}
				</motion.div>
			)}
		</AnimatePresence>
	);
}

function KeyCap({ children }: { children: React.ReactNode }) {
	return (
		<span className="inline-flex items-center justify-center w-7 h-7 rounded-md bg-foreground/10 border border-foreground/15 text-xs font-mono shadow-sm">
			{children}
		</span>
	);
}

function SwipeIcon() {
	return (
		<svg
			width="24"
			height="24"
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			strokeWidth="1.5"
			strokeLinecap="round"
			strokeLinejoin="round"
			className="opacity-70"
		>
			<path d="M5 12h14" />
			<path d="M2 12l3-3M2 12l3 3" />
			<path d="M22 12l-3-3M22 12l-3 3" />
		</svg>
	);
}
components/ui/slide-cn/use-deck-controller.ts
"use client";
import { useCallback, useState } from 'react';

export function useDeckController(slideCount: number) {
	const [index, setIndex] = useState(0);

	const clamp = useCallback(
		(slideIndex: number) => {
			if (slideIndex < 0) return 0;
			if (slideIndex >= slideCount) return slideCount - 1;
			return slideIndex;
		},
		[slideCount]
	);

	const next = useCallback(() => {
		setIndex((i) => clamp(i + 1));
	}, [clamp]);

	const prev = useCallback(() => {
		setIndex((i) => clamp(i - 1));
	}, [clamp]);

	const goTo = useCallback(
		(target: number) => {
			setIndex(clamp(target));
		},
		[clamp]
	);

	return {
		index,
		next,
		prev,
		goTo,
		total: slideCount,
	};
}
components/ui/slide-cn/use-keyboard-navigation.ts
import { useEffect } from "react";

type KeyboardNavigationOptions = {
	onNext: () => void;
	onPrev: () => void;
	disabled?: boolean;
};

export function useKeyboardNavigation({
	onNext,
	onPrev,
	disabled = false,
}: KeyboardNavigationOptions) {
	useEffect(() => {
		if (disabled) return;

		const handler = (e: KeyboardEvent) => {
			// Ignore key repeat (holding down key)
			if (e.repeat) return;

			if (e.key === "ArrowRight" || e.key === " ") {
				e.preventDefault();
				onNext();
			}

			if (e.key === "ArrowLeft") {
				e.preventDefault();
				onPrev();
			}
		};

		window.addEventListener("keydown", handler);
		return () => {
			window.removeEventListener("keydown", handler);
		};
	}, [onNext, onPrev, disabled]);
}

More from slide-cn

Browse all components in this library →