Skip to content

Step Demo Showcase

A step list that drives small live product scenes in a square card beside it.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/step-demo-showcase.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/step-demo-showcase/step-demo-showcase.tsx
    "use client";
    
    import { useCallback, useEffect, useId, useRef, useState, type ComponentProps, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
    import { AnimatePresence, LayoutGroup, animate, motion, useInView, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
    import { Pause, Play } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    
    export interface StepDemoStep {
      /** Stable id, used for keys and callbacks. */
      id: string;
      /** Step title. */
      title: string;
      /** One muted line under the title. */
      description: string;
      /** What the media card shows for this step. It is treated as an illustration and hidden from assistive tech. */
      scene: ReactNode;
    }
    
    export interface StepDemoShowcaseProps extends Omit<ComponentProps<"section">, "children" | "title"> {
      /** Headline. Wrap the connecting words in a muted span for the two-tone look. */
      title?: ReactNode;
      /** Steps, in order. Each one drives the scene in the media card. */
      steps: StepDemoStep[];
      /** Seconds each step stays up while autoplaying. @default 3 */
      interval?: number;
      /** Advance through the steps automatically while in view. Stops for good once someone picks a step. @default true */
      autoplay?: boolean;
      /** Called when the active step changes. */
      onStepChange?: (step: StepDemoStep, index: number) => void;
    }
    
    export function StepDemoShowcase({ title, steps, interval = 3, autoplay = true, onStepChange, className, ...props }: StepDemoShowcaseProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const rootRef = useRef<HTMLElement>(null);
      const inView = useInView(rootRef, { amount: 0.35 });
      const [index, setIndex] = useState(0);
      /** Autoplay is on until someone picks a step or presses pause. */
      const [playing, setPlaying] = useState(autoplay);
      /** A step is hovered: autoplay waits. */
      const [hovering, setHovering] = useState(false);
      const tabs = useRef<(HTMLButtonElement | null)[]>([]);
      const swipe = useRef<{ x: number; y: number } | null>(null);
      const progress = useMotionValue(0);
      const count = steps.length;
      const active = Math.min(index, Math.max(0, count - 1));
      const running = playing && !hovering && inView && !reduceMotion && count > 1;
    
      const latest = useRef({ steps, onStepChange });
      useEffect(() => {
        latest.current = { steps, onStepChange };
      });
    
      const show = useCallback((next: number) => {
        setIndex(next);
        const step = latest.current.steps[next];
        if (step) latest.current.onStepChange?.(step, next);
      }, []);
    
      /** A deliberate choice: show the step and stop autoplay for good. */
      const pick = (next: number, focus = false) => {
        const clamped = (next + count) % count;
        setPlaying(false);
        show(clamped);
        if (focus) tabs.current[clamped]?.focus();
      };
    
      // The indicator bar is the timer: it fills over the interval and then the next step shows.
      useEffect(() => {
        let controls: AnimationPlaybackControls | undefined;
        if (running) {
          controls = animate(progress, 1, {
            duration: interval * (1 - progress.get()),
            ease: "linear",
            onComplete: () => {
              progress.jump(0);
              show((active + 1) % count);
            },
          });
        } else if (!playing || reduceMotion) {
          // Not autoplaying: the bar simply grows to the full height of the active step.
          if (reduceMotion) progress.jump(1);
          else controls = animate(progress, 1, { duration: duration.base, ease: ease.out });
        }
        return () => controls?.stop();
      }, [running, playing, reduceMotion, active, count, interval, progress, show]);
    
      function onTabKeyDown(event: KeyboardEvent<HTMLButtonElement>, at: number) {
        const target = { ArrowDown: at + 1, ArrowRight: at + 1, ArrowUp: at - 1, ArrowLeft: at - 1, Home: 0, End: count - 1 }[event.key];
        if (target === undefined) return;
        event.preventDefault();
        progress.jump(0);
        pick(target, true);
      }
    
      function onCardPointerUp(event: PointerEvent<HTMLDivElement>) {
        const start = swipe.current;
        swipe.current = null;
        if (!start || event.pointerType === "mouse") return;
        const dx = event.clientX - start.x;
        if (Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(event.clientY - start.y)) return;
        progress.jump(0);
        pick(active + (dx < 0 ? 1 : -1));
      }
    
      // Keep the active tab in view when the steps are a horizontal row.
      useEffect(() => {
        const tab = tabs.current[active];
        const list = tab?.parentElement?.parentElement;
        if (!tab || !list || list.scrollWidth <= list.clientWidth + 1) return;
        list.scrollTo({ left: tab.offsetLeft - 16, behavior: reduceMotion ? "auto" : "smooth" });
      }, [active, reduceMotion]);
    
      const step = steps[active];
      const enter = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98, filter: "blur(8px)" };
    
      return (
        <section ref={rootRef} className={cn("@container w-full", className)} {...props}>
          {title ? <h2 className="max-w-2xl text-3xl leading-[1.12] font-semibold tracking-tight text-balance text-foreground @[38rem]:text-[2.6rem]">{title}</h2> : null}
    
          <div className={cn("grid grid-cols-1 gap-6 @[38rem]:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] @[38rem]:items-center @[38rem]:gap-12", title && "mt-8 @[38rem]:mt-10")}>
            <div
              id={`${id}-panel`}
              role="tabpanel"
              aria-labelledby={`${id}-tab-${active}`}
              onPointerDown={(event) => {
                swipe.current = { x: event.clientX, y: event.clientY };
              }}
              onPointerUp={onCardPointerUp}
              onPointerCancel={() => {
                swipe.current = null;
              }}
              className="relative mx-auto aspect-square w-full max-w-md touch-pan-y overflow-hidden rounded-3xl border border-border bg-surface @[38rem]:max-w-none"
            >
              {/* Scenes overlap while they swap, so elements that share a layoutId glide from one to the next. */}
              <LayoutGroup id={id}>
                <AnimatePresence initial={false}>
                  <motion.div
                    key={step?.id}
                    aria-hidden="true"
                    initial={enter}
                    animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                    exit={enter}
                    transition={{ duration: duration.base, ease: ease.out }}
                    className="absolute inset-0"
                  >
                    {step?.scene}
                  </motion.div>
                </AnimatePresence>
              </LayoutGroup>
              {autoplay && count > 1 ? (
                <button
                  type="button"
                  aria-label={playing ? "Pause autoplay" : "Play autoplay"}
                  onClick={() => {
                    // Resuming starts the current step's timer over.
                    if (!playing) progress.jump(0);
                    setPlaying(!playing);
                  }}
                  className="absolute right-3 bottom-3 z-10 grid size-10 cursor-pointer place-items-center rounded-full border border-border bg-background/90 text-foreground shadow-xs outline-none backdrop-blur-sm transition-colors hover:bg-background focus-visible:ring-2 focus-visible:ring-ring motion-reduce:hidden"
                >
                  {playing ? <Pause aria-hidden="true" className="size-4" fill="currentColor" strokeWidth={0} /> : <Play aria-hidden="true" className="size-4" fill="currentColor" strokeWidth={0} />}
                </button>
              ) : null}
            </div>
    
            <div className="-mx-4 overflow-x-auto px-4 [scrollbar-width:none] @[38rem]:mx-0 @[38rem]:overflow-visible @[38rem]:px-0 [&::-webkit-scrollbar]:hidden">
              <div role="tablist" aria-orientation="vertical" aria-label="Steps" className="flex snap-x snap-mandatory gap-3 @[38rem]:flex-col @[38rem]:gap-1" onPointerLeave={() => setHovering(false)}>
                {steps.map((item, at) => {
                  const selected = at === active;
                  return (
                    <button
                      key={item.id}
                      ref={(node) => {
                        tabs.current[at] = node;
                      }}
                      id={`${id}-tab-${at}`}
                      type="button"
                      role="tab"
                      aria-selected={selected}
                      aria-controls={`${id}-panel`}
                      tabIndex={selected ? 0 : -1}
                      onClick={() => {
                        progress.jump(0);
                        pick(at);
                      }}
                      onKeyDown={(event) => onTabKeyDown(event, at)}
                      onPointerEnter={(event) => {
                        // Hovering previews a step and holds autoplay; leaving the list lets it carry on.
                        if (event.pointerType !== "mouse") return;
                        setHovering(true);
                        if (!selected) {
                          progress.jump(0);
                          show(at);
                        }
                      }}
                      className={cn(
                        "group/step relative w-[78%] max-w-xs shrink-0 cursor-pointer snap-start rounded-r-xl py-3 pr-3 pl-5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring @[38rem]:w-auto @[38rem]:max-w-none",
                        selected ? "text-foreground" : "text-foreground/65 hover:text-foreground/85",
                      )}
                    >
                      <span aria-hidden="true" className="absolute inset-y-0 left-0 w-0.5 rounded-full bg-border" />
                      {selected ? <motion.span aria-hidden="true" style={{ scaleY: progress }} className="absolute inset-y-0 left-0 w-0.5 origin-top rounded-full bg-foreground" /> : null}
                      <span className="block text-base font-semibold tracking-tight">{item.title}</span>
                      <span className="mt-1 block text-sm leading-5 text-muted-foreground">{item.description}</span>
                    </button>
                  );
                })}
              </div>
            </div>
          </div>
        </section>
      );
    }

Usage

import { StepDemoShowcase } from "@/components/ui/step-demo-showcase/step-demo-showcase";

const steps = [
  { id: "style", title: "Pick a style", description: "Browse looks and choose one.", scene: <StyleScene /> },
  { id: "prompt", title: "Prompt your agent", description: "Say what you need in a few words.", scene: <PromptScene /> },
  { id: "asset", title: "Use the asset", description: "Drop it straight into your app.", scene: <ResultScene /> },
];

export function HowItWorks() {
  return (
    <StepDemoShowcase
      title={<><span className="text-muted-foreground">From</span> idea <span className="text-muted-foreground">to</span> visuals</>}
      steps={steps}
    />
  );
}

API reference

PropTypeDefaultDescription
titleReactNode–Headline. Wrap the connecting words in a muted span for the two-tone look.
steps*StepDemoStep[]–Steps, in order. Each one drives the scene in the media card.
intervalnumber3Seconds each step stays up while autoplaying.
autoplaybooleantrueAdvance through the steps automatically while in view. Stops for good once someone picks a step.
onStepChange(step: StepDemoStep, index: number) => void–Called when the active step changes.

Also accepts every native <section> attribute, including ref and className.

  • The thin bar on the active step is the timer: it fills from top to bottom over interval seconds, then the next step shows. With autoplay off it simply grows to the step's height.
  • Scenes are your own React nodes and remount each time their step shows, so their entrance animations replay. Under reduced motion, keep those entrances calm yourself.
  • Outgoing and incoming scenes overlap inside one LayoutGroup. Give an element the same layoutId in two scenes and it glides between them.
  • Below 38rem of the component's own width the card moves on top and the steps become a horizontal scroll-snap row; swiping the card left or right changes the step.
  • The demo's swatches, fluid art and coin jar are original CSS and SVG drawn in code, with no image files.

Accessibility

  • The steps are a tablist and the media card is its tabpanel. Arrow keys move between steps, Home and End jump to the first and last, and the active tab is the only tab stop.
  • A visible pause button stops autoplay. Picking a step by click, keyboard or swipe also stops it for good; hovering a step only holds it until the pointer leaves the list.
  • Scenes are treated as illustrations and hidden from assistive tech. The step titles and descriptions carry the meaning, so write them to stand on their own.
  • Autoplay runs only while the section is in view.
  • With reduced motion enabled there is no autoplay and the pause button is hidden; scenes crossfade without blur or scale.