Skip to content

Assistant

    Chat showcase

    A phone rises over a softly breathing gradient and plays one conversation after another, a message a second. A stepper along the top fills as each one plays, and jumps to any of them when you click it.

    Uses
    motion

    Code

    import { useCallback, useState, type ComponentProps, type CSSProperties } from "react";
    import { AnimatePresence, motion, useReducedMotion, type MotionProps } from "motion/react";
    import { cn } from "@manikrana/ui/lib/utils";
    import { IPhone } from "@/work/iphone";
    import { Gradient, type GradientStop } from "./gradient";
    import { MessagingApp, type ChatContact, type ChatMessage } from "./messaging-app";
    import { Stepper } from "./stepper";
    import { useStepPlayback } from "./use-step-playback";
    
    export type ChatShowcaseStep = { label: string; messages: readonly ChatMessage[] };
    
    export type ChatShowcaseProps = Omit<ComponentProps<"div">, "children"> & {
      steps: readonly ChatShowcaseStep[];
      contact: ChatContact;
      step?: number;
      defaultStep?: number;
      onStepChange?: (step: number) => void;
      paused?: boolean;
      appear?: boolean;
      messageDelay?: number;
      hold?: number;
      gradient?: readonly GradientStop[];
    };
    
    // tan(atan2(a, b)) is the unitless ratio a / b. 288px is the phone's width and the height of it that must show.
    const FIT =
      "max(0.1, min(tan(atan2(100cqw - 48px, 288px)), tan(atan2(100cqh - 56px, 288px)), 1.5))";
    
    const SLIDE = {
      initial: { y: "100%" },
      animate: { y: 0, transition: { delay: 0.1, duration: 0.4, ease: [0.25, 1, 0.5, 1] } },
      exit: { opacity: 0.7, scale: 0.85, y: 50 },
      transition: { duration: 0.5 },
    } satisfies MotionProps;
    
    const FADE = {
      initial: { opacity: 0 },
      animate: { opacity: 1 },
      exit: { opacity: 0 },
      transition: { duration: 0.2, ease: [0.25, 0.1, 0.25, 1] },
    } satisfies MotionProps;
    
    function useControllable<T>(value: T | undefined, initial: T, onChange?: (next: T) => void) {
      const [local, setLocal] = useState(initial);
      const controlled = value !== undefined;
      const current = controlled ? value : local;
      const set = useCallback(
        (next: T) => {
          if (next === current) return;
          if (!controlled) setLocal(next);
          onChange?.(next);
        },
        [controlled, current, onChange],
      );
      return [current, set] as const;
    }
    
    export function ChatShowcase({
      steps,
      contact,
      step: stepProp,
      defaultStep = 0,
      onStepChange,
      paused = false,
      appear = true,
      messageDelay = 1000,
      hold = 1500,
      gradient,
      className,
      style,
      ...props
    }: ChatShowcaseProps) {
      const reduced = useReducedMotion();
      const [step, setStep] = useControllable(stepProp, defaultStep, onStepChange);
      const current = steps[step];
      const count = current?.messages.length ?? 0;
      const { shown } = useStepPlayback({
        step,
        count,
        messageDelay,
        hold,
        paused,
        onComplete: () => {
          if (steps.length > 1) setStep((step + 1) % steps.length);
        },
      });
    
      return (
        <div
          data-slot="chat-showcase"
          style={{ ...style, "--fit": FIT } as CSSProperties}
          className={cn(
            "@container-size relative h-full w-full overflow-hidden bg-white text-emerald-800",
            className,
          )}
          {...props}
        >
          <Gradient stops={gradient} paused={paused} appear={appear} />
          <Stepper
            labels={steps.map(({ label }) => label)}
            step={step}
            duration={count * messageDelay + hold}
            paused={paused}
            onStepChange={setStep}
            className="absolute inset-x-6 top-4 z-10"
          />
          <div className="absolute inset-x-0 top-14 flex justify-center">
            <div className="relative h-72 w-72 shrink-0 origin-top scale-(--fit)">
              <AnimatePresence initial={false}>
                {current && (
                  <motion.div
                    key={step}
                    className="absolute inset-x-0 top-0"
                    {...(reduced ? FADE : SLIDE)}
                  >
                    <IPhone className="w-full">
                      <div
                        aria-hidden="true"
                        className="absolute top-2 left-1/2 z-20 h-6 w-20 -translate-x-1/2 rounded-full bg-black"
                      />
                      <MessagingApp
                        contact={contact}
                        messages={current.messages.slice(0, shown)}
                        className="h-68"
                      />
                    </IPhone>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </div>
        </div>
      );
    }