slide-cn
componentdecksource: ok · refreshed
Deck
A Deck component that is the core of the presentation
npx shadcn add "https://slide-cn.com/r/deck.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files7 · source cached 10/6/2026, 11:59:58 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-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>←</FooterKeyCap>
<FooterKeyCap>→</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/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/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>←</KeyCap>
<KeyCap>→</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]);
}