"use client"; /** * RoadmapCard, from https://motion-brew.com/components/roadmap-card * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * The animated icons are adapted from lucide-animated (https://lucide-animated.com), * MIT License, Copyright (c) 2024-2026 pqoqubbw. Icon shapes from Lucide * (https://lucide.dev), ISC License. Permission is hereby granted, free of * charge, to any person obtaining a copy of this software, to deal in it * without restriction, provided the above copyright notice and this * permission notice are included in all copies or substantial portions. */ import * as React from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { type ClassValue, clsx } from "clsx"; import { animate, AnimatePresence, motion, type MotionValue, type Transition, useAnimation, useInView, useMotionValue, useReducedMotion, useTransform, type Variants } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Animated icon: check const CheckIcon = (() => { interface CheckIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface CheckIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PATH_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, scale: 1, transition: { duration: 0.3, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], scale: [0.5, 1], transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, }; const CheckIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); CheckIcon.displayName = "CheckIcon"; return CheckIcon; })(); // Animated icon: chevron-left const ChevronLeftIcon = (() => { interface ChevronLeftIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ChevronLeftIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const DEFAULT_TRANSITION: Transition = { times: [0, 0.4, 1], duration: 0.5, }; const ChevronLeftIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); ChevronLeftIcon.displayName = "ChevronLeftIcon"; return ChevronLeftIcon; })(); // Animated icon: chevron-right const ChevronRightIcon = (() => { interface ChevronRightIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ChevronRightIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const DEFAULT_TRANSITION: Transition = { times: [0, 0.4, 1], duration: 0.5, }; const ChevronRightIcon = forwardRef< ChevronRightIconHandle, ChevronRightIconProps >(({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); }); ChevronRightIcon.displayName = "ChevronRightIcon"; return ChevronRightIcon; })(); // RoadmapCard /** * RoadmapCard * * Trigger: press (plus keys and in view). A white card that walks through the * phases of a product roadmap like stories. A row of bars across the top has * one bar per phase: past ones full, the current one filling over the time * each phase is shown, the rest empty; any bar jumps to its phase, and a * small Playing chip pauses and resumes it. A big two digit phase number rolls * like an odometer next to the title, the title and body blur across in the * direction of travel, and a short list of milestones follows with each check * drawing in one after another. Round previous and next buttons with animated * chevrons step through, and so do the arrow keys. Reduced motion stops it * playing on its own and swaps the moves for plain changes. * * Engine: motion/react (a motion value for the story fill that pauses and * resumes from the chip, rolling digit columns, AnimatePresence for the text and the list) * with animated icons from lucide-animated (MIT), vendored in src/icons. */ export interface RoadmapPhase { /** Short name, like "Private beta". Shown as the title. */ title: string; body: string; /** A few milestones, checked off under the body. */ focus?: string[]; } export interface RoadmapCardProps { /** Small label at the top. Default `"Product roadmap"`. */ label?: string; phases?: RoadmapPhase[]; /** Phase shown first. Default `2` (the third). */ defaultPhase?: number; /** Seconds per phase while it plays on its own; `0` turns it off. Default `5`. */ autoplay?: number; onChange?: (index: number) => void; className?: string; } const DEFAULT_PHASES: RoadmapPhase[] = [ { title: "Discovery", body: "Talk to customers, map the problem and agree on what success looks like.", focus: ["Twelve customer calls", "Problem brief signed off"], }, { title: "Design", body: "Shape the flows, test prototypes and lock the scope for the first release.", focus: ["Prototype tested with users", "Scope frozen"], }, { title: "Private beta", body: "Ship to a small group of design partners and fix what they run into.", focus: ["Ten design partners live", "Weekly feedback review"], }, { title: "Public launch", body: "Open the doors, announce it and watch adoption, errors and support load.", focus: ["Launch post and changelog", "Status page green"], }, { title: "General availability", body: "Harden for scale, add enterprise controls and turn on billing for everyone.", focus: ["SSO and audit log", "Pricing live"], }, ]; const GLIDE = [0.22, 1, 0.36, 1] as const; // Number rolls, the same as GoalGauge and SpendingMeter: one 0 to 9 column per digit. const ROLL = { type: "spring", stiffness: 280, damping: 26, mass: 0.8, } as const; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const; function Digit({ digit, still }: { digit: number; still: boolean }) { return ( {DIGITS.map((d) => ( {d} ))} ); } /** One story bar: full when past, filling from a motion value when current, empty when ahead. */ function Bar({ state, fill, label, onPick, }: { state: "past" | "now" | "next"; fill: MotionValue; label: string; onPick: () => void; }) { const width = useTransform(fill, (f) => `${f * 100}%`); return ( ); } export const RoadmapCard = React.forwardRef( function RoadmapCard( { label = "Product roadmap", phases = DEFAULT_PHASES, defaultPhase = 2, autoplay = 5, onChange, className, }, ref, ) { const root = React.useRef(null); React.useImperativeHandle(ref, () => root.current!); const still = !!useReducedMotion(); const inView = useInView(root, { amount: 0.4 }); const n = phases.length; const [at, setAt] = React.useState( Math.min(n - 1, Math.max(0, defaultPhase)), ); const [dir, setDir] = React.useState(1); const [paused, setPaused] = React.useState(false); const fill = useMotionValue(0); const go = React.useCallback( (to: number) => { const next = ((to % n) + n) % n; setDir(next >= at ? 1 : -1); setAt(next); fill.set(0); onChange?.(next); }, [at, n, onChange, fill], ); // The current bar fills over the phase's time, pauses on hover, then steps on. const playing = !!autoplay && !still && inView && !paused; React.useEffect(() => { if (!playing) return; const left = (1 - fill.get()) * autoplay; const c = animate(fill, 1, { duration: Math.max(0.05, left), ease: "linear", onComplete: () => go(at + 1), }); return () => c.stop(); }, [playing, at, autoplay, fill, go]); const phase = phases[at]!; const slide = still ? 0 : 24; const num = String(at + 1).padStart(2, "0"); return ( { if (e.key === "ArrowRight") { e.preventDefault(); go(at + 1); } else if (e.key === "ArrowLeft") { e.preventDefault(); go(at - 1); } }} initial={still ? false : { opacity: 0, y: 16 }} animate={inView || still ? { opacity: 1, y: 0 } : undefined} transition={{ duration: 0.6, ease: GLIDE }} className={cn( "w-[360px] max-w-full rounded-3xl bg-white p-6 text-[#242423] shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(0,0,0,0.18)] outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-zinc-900 dark:text-zinc-100", className, )} > {/* Story bars */}
{phases.map((p, i) => ( go(i)} /> ))}

{label}

setPaused((x) => !x)} whileTap={still ? undefined : { scale: 0.94 }} className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full bg-[#242423]/[0.06] px-2.5 text-xs font-medium text-zinc-500 outline-none transition-colors hover:bg-[#242423]/[0.1] hover:text-[#242423] focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-white/[0.08] dark:text-zinc-400" > {playing ? "Playing" : "Paused"}
{/* Number and title */}
{Array.from(num).map((ch, i) => ( ))}
{phase.title}
{/* Body and focus points */}

{phase.body}

{phase.focus?.length ? (
    {phase.focus.map((f, i) => ( {f} ))}
) : null}
Phase {at + 1} of {n}
{[ { label: "Previous phase", to: at - 1, Icon: ChevronLeftIcon }, { label: "Next phase", to: at + 1, Icon: ChevronRightIcon }, ].map(({ label: l, to, Icon }) => ( go(to)} whileTap={still ? undefined : { scale: 0.9 }} className={cn( "grid size-10 cursor-pointer place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-zinc-400", l === "Next phase" ? "bg-[#242423] text-white hover:bg-[#242423]/90 dark:bg-zinc-100 dark:text-[#242423]" : "bg-[#242423]/[0.06] text-[#242423] hover:bg-[#242423]/[0.1] dark:bg-white/[0.08] dark:text-zinc-100", )} > ))}
); }, );