"use client"; import { motion, useInView, useReducedMotion } from "motion/react"; import { type CSSProperties, type KeyboardEvent, type ReactNode, useCallback, useId, useRef, useState, } from "react"; import { EASE_IN_OUT, EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface HowItWorksStep { id: string; title: ReactNode; description?: ReactNode; icon?: ReactNode; /** Shown in the media panel while this step is active. */ media?: ReactNode; } export interface HowItWorksProps { /** 3–5 steps read best; more still work. */ steps: HowItWorksStep[]; eyebrow?: ReactNode; heading?: ReactNode; description?: ReactNode; /** Controlled active step id (only meaningful when steps carry `media`). */ value?: string; /** Initial active step id when uncontrolled. Defaults to the first step. */ defaultValue?: string; onValueChange?: (id: string) => void; /** Seconds the connector takes to travel between two markers. Default 0.45. */ segmentDuration?: number; className?: string; /** Classes for the media panel (aspect ratio, height). */ mediaClassName?: string; } // Must match the gap-6 used by the list in both orientations; connector // segments span exactly one item plus one gap. const GAP = "1.5rem"; // Marker diameter (size-10). const MARKER = "2.5rem"; /** * One connector piece between marker `index` and marker `index + 1`. Plain * HTML so `scaleX`/`scaleY` grow from a CSS origin motion leaves alone. * Two copies render — one per orientation — and container queries pick one. */ function Segment({ index, drawn, duration, reduce, }: { index: number; drawn: boolean; duration: number; reduce: boolean | null; }) { const transition = reduce ? { duration: 0.2, delay: index * 0.05 } : { duration, ease: EASE_IN_OUT, delay: 0.15 + index * duration }; const vertical: CSSProperties = { top: MARKER, left: `calc(${MARKER} / 2 - 1px)`, height: `calc(100% - ${MARKER} + ${GAP})`, }; const horizontal: CSSProperties = { top: `calc(${MARKER} / 2 - 1px)`, left: `calc(50% + ${MARKER} / 2)`, width: `calc(100% - ${MARKER} + ${GAP})`, }; return ( <> ); } /** * "How it works" section: numbered steps joined by a connector that draws * from marker to marker as the section scrolls into view, each marker * lighting up as the line reaches it. Steps sit in a row on wide containers * and become a vertical timeline on narrow ones. When steps carry `media`, * they act as tabs: hovering, focusing or pressing one cross-fades the media * panel below to that step. */ export function HowItWorks({ steps, eyebrow, heading, description, value, defaultValue, onValueChange, segmentDuration = 0.45, className, mediaClassName, }: HowItWorksProps) { const reduce = useReducedMotion(); const listRef = useRef(null); const drawn = useInView(listRef, { once: true, amount: 0.4 }); const baseId = useId(); const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); const hasMedia = steps.some((step) => step.media != null); const [internal, setInternal] = useState(defaultValue ?? steps[0]?.id ?? ""); const active = value ?? internal; const select = useCallback( (id: string) => { if (id === active) return; if (value === undefined) setInternal(id); onValueChange?.(id); }, [active, value, onValueChange], ); const onKeyDown = (event: KeyboardEvent, index: number) => { const last = steps.length - 1; let next: number | null = null; if (event.key === "ArrowRight" || event.key === "ArrowDown") { next = index === last ? 0 : index + 1; } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { next = index === 0 ? last : index - 1; } else if (event.key === "Home") next = 0; else if (event.key === "End") next = last; if (next === null) return; event.preventDefault(); const step = steps[next]; if (!step) return; select(step.id); tabRefs.current[next]?.focus(); }; // Each marker fills when the connector arrives at it. const markerDelay = (index: number) => reduce ? index * 0.05 : 0.15 + index * segmentDuration; const header = eyebrow || heading || description ? (
{eyebrow ? (

{eyebrow}

) : null} {heading ? (

{heading}

) : null} {description ? (

{description}

) : null}
) : null; return (
{header}
    {steps.map((step, index) => { const isActive = hasMedia && step.id === active; const marker = ( {index + 1} {index + 1} ); const card = ( {step.icon ? ( {step.icon} ) : null} {step.title} {step.description ? ( {step.description} ) : null} ); const inner = ( <> {marker} {card} ); const itemClass = "group/step relative flex w-full gap-4 @xl:flex-col @xl:items-center"; return ( {index < steps.length - 1 ? ( ) : null} {hasMedia ? ( ) : (
    {inner}
    )}
    ); })}
{hasMedia ? (
{/* Every media stays mounted in one grid cell, so the panel keeps the tallest step's height and switching never shifts layout. */} {steps.map((step) => { const shown = step.id === active; return ( {step.media} ); })}
) : null}
); }