"use client"; import { Pause, Play } from "lucide-react"; import { AnimatePresence, motion, useInView, useMotionValue, useReducedMotion, } from "motion/react"; import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useId, useRef, useState, } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface FeatureSectionItem { id: string; title: string; description: ReactNode; icon?: ReactNode; /** What the visual panel shows while this feature is active. */ visual: ReactNode; } export interface FeatureSectionProps { features: FeatureSectionItem[]; eyebrow?: ReactNode; heading?: ReactNode; description?: ReactNode; /** Controlled active feature id. */ value?: string; defaultValue?: string; onValueChange?: (id: string) => void; /** Advance through features on a timer. Default true. */ autoplay?: boolean; /** Milliseconds per feature. Default 6000. */ interval?: number; /** Visual panel side at wide sizes. Default "right". */ visualPosition?: "left" | "right"; /** Classes for the visual panel (set its height here). Default `h-72`. */ visualClassName?: string; className?: string; } /** * Marketing features block: a vertical tab list of features beside a visual * panel that cross-fades and slides (in the direction of travel) to each * feature's visual. With `autoplay`, the active row fills a progress rail and * advances when it completes; hovering or focusing the block pauses it, and a * pause button stops it for good. Only runs while on screen. */ export function FeatureSection({ features, eyebrow, heading, description, value: valueProp, defaultValue, onValueChange, autoplay = true, interval = 6000, visualPosition = "right", visualClassName, className, }: FeatureSectionProps) { const reduce = useReducedMotion(); const baseId = useId(); const rootRef = useRef(null); const inView = useInView(rootRef, { amount: 0.3 }); const [internal, setInternal] = useState(defaultValue ?? features[0]?.id ?? ""); const controlled = valueProp !== undefined; const active = controlled ? valueProp : internal; const [dir, setDir] = useState<1 | -1>(1); const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const [stopped, setStopped] = useState(false); const progress = useMotionValue(0); const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); const activeIndex = Math.max( 0, features.findIndex((f) => f.id === active), ); const select = useCallback( (index: number) => { const target = features[(index + features.length) % features.length]; if (!target) return; setDir(index >= activeIndex ? 1 : -1); progress.set(0); if (!controlled) setInternal(target.id); onValueChange?.(target.id); }, [features, activeIndex, controlled, onValueChange, progress], ); const running = autoplay && !stopped && !hovered && !focused && inView && features.length > 1; // A frame loop owns the progress value so pausing holds the exact position. const selectRef = useRef(select); selectRef.current = select; useEffect(() => { if (!running) return; let frame = 0; let last = performance.now(); const tick = (now: number) => { const next = progress.get() + (now - last) / interval; last = now; if (next >= 1) { selectRef.current(activeIndex + 1); } else progress.set(next); frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [running, interval, progress, activeIndex]); const onKeyDown = (event: KeyboardEvent, index: number) => { const map: Record = { ArrowDown: index + 1, ArrowRight: index + 1, ArrowUp: index - 1, ArrowLeft: index - 1, Home: 0, End: features.length - 1, }; if (!(event.key in map)) return; event.preventDefault(); const next = (map[event.key] + features.length) % features.length; select(next); tabRefs.current[next]?.focus(); }; const current = features[activeIndex]; const panelId = `${baseId}-panel`; const slide = { enter: (d: number) => reduce ? { opacity: 0 } : { opacity: 0, y: d * 28, scale: 0.98 }, center: { opacity: 1, y: 0, scale: 1 }, exit: (d: number) => reduce ? { opacity: 0 } : { opacity: 0, y: d * -28, scale: 0.98, transition: { duration: 0.2, ease: EASE_OUT } }, }; return ( // biome-ignore lint/a11y/noStaticElementInteractions: hover/focus only pause autoplay; the controls inside are real tabs and buttons.
setHovered(true)} onPointerLeave={() => setHovered(false)} onFocus={() => setFocused(true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }} className={cn("@container w-full", className)} > {eyebrow || heading || description ? (
{eyebrow ? (

{eyebrow}

) : null} {heading ? (

{heading}

) : null} {description ?

{description}

: null}
) : null}
{features.map((feature, i) => { const isActive = i === activeIndex; return ( ); })}
{autoplay && features.length > 1 ? ( ) : null}
{current ? ( {current.visual} ) : null}
); }