Split Hero with Self-Playing Chat Demo

A two-column hero with the pitch on the left and a product demo on the right that plays itself. Inside a floating conversation card, a customer asks to move an appointment, a reply writes itself word by word, two time slots appear, the customer's answer is typed into the message box and sent, and a confirmation card arrives with a tick that draws itself. Then it starts again. On phones the demo sits under the text at full width.

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

/**
 * The demo is a list of steps. Each step stays on screen for `hold` milliseconds, then the next one plays.
 * Edit the text here to tell your own story; the timings follow the order.
 */
const script = {
  first: "Hi! Can I move my Thursday haircut to Saturday?",
  offer: "Of course, Dana. Mara has two openings on Saturday. Which one suits you?",
  slots: ["10:30 am", "2:15 pm"],
  pick: "2:15 works, thanks",
  done: "Done. Your Thursday slot is released and you are booked for Saturday.",
  booking: { title: "Cut and finish with Mara", when: "Sat 14 June, 2:15 pm", where: "Alder & Finch, Queen St" },
};

// step: 0 typing, 1 offer, 2 slots, 3 customer types, 4 customer sent, 5 typing, 6 confirmation, 7 hold
const holds = [900, 900, 700, 1200, 600, 1000, 900, 3600];
const LAST = holds.length - 1;

const points = ["Answers in under five seconds, day and night", "Reads your real calendar, never double books", "Hands over to a person when it should"];

function Words({ text, still }: { text: string; still: boolean }) {
  return (
    <>
      {text.split(" ").map((w, i) => (
        <motion.span key={i} initial={still ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.25, delay: i * 0.035 }} className="inline-block whitespace-pre">
          {w + " "}
        </motion.span>
      ))}
    </>
  );
}

function Dots() {
  return (
    <span className="flex items-center gap-1 py-1.5">
      {[0, 1, 2].map((i) => (
        <span key={i} className="shc-dot h-1.5 w-1.5 rounded-full bg-slate-400" style={{ animationDelay: `${i * 0.15}s` }} />
      ))}
    </span>
  );
}

export default function SplitHeroWithSelfPlayingChatDemo() {
  const reduce = !!useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const [onScreen, setOnScreen] = useState(true);
  const [step, setStep] = useState(0);
  const [loop, setLoop] = useState(0);
  const [typed, setTyped] = useState(0);
  const shown = reduce ? LAST : step;

  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  // Move to the next step after this one's hold. After the last step the conversation starts again.
  useEffect(() => {
    if (reduce || !onScreen) return;
    const id = setTimeout(() => {
      if (step === LAST) {
        setLoop((l) => l + 1);
        setStep(0);
      } else setStep(step + 1);
    }, holds[step]);
    return () => clearTimeout(id);
  }, [step, reduce, onScreen]);

  // While the customer is "typing", reveal the reply one character at a time in the message box.
  useEffect(() => {
    if (shown !== 3) {
      setTyped(0);
      return;
    }
    const id = setInterval(() => setTyped((t) => Math.min(t + 1, script.pick.length)), 48);
    return () => clearInterval(id);
  }, [shown]);

  const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2";
  const pop = { initial: reduce ? false : { opacity: 0, y: 18, scale: 0.92 }, animate: { opacity: 1, y: 0, scale: 1 }, transition: { type: "spring", stiffness: 320, damping: 26 } } as const;
  const mine = "ml-auto max-w-[80%] origin-bottom-right rounded-2xl rounded-br-md bg-blue-600 px-4 py-2.5 text-sm text-white";
  const theirs = "mr-auto max-w-[85%] origin-bottom-left rounded-2xl rounded-bl-md bg-slate-100 px-4 py-2.5 text-sm text-slate-800";

  return (
    <section ref={ref} data-paused={!onScreen} className="shc-root overflow-hidden bg-white px-6 py-16 text-slate-900 sm:py-24">
      <style>{`
        @keyframes split-hero-with-self-playing-chat-demo-dot { 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; } 30% { transform: translateY(-4px); opacity: 1; } }
        @keyframes split-hero-with-self-playing-chat-demo-caret { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
        @keyframes split-hero-with-self-playing-chat-demo-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }
        .shc-dot { animation: split-hero-with-self-playing-chat-demo-dot 1s ease-in-out infinite; }
        .shc-caret { animation: split-hero-with-self-playing-chat-demo-caret 0.9s steps(1) infinite; }
        .shc-float { animation: split-hero-with-self-playing-chat-demo-float 7s ease-in-out infinite; }
        .shc-root[data-paused="true"] * { animation-play-state: paused !important; }
        @media (prefers-reduced-motion: reduce) { .shc-dot, .shc-caret, .shc-float { animation: none; } }
      `}</style>

      <div className="mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-2 lg:gap-16">
        <motion.div initial={reduce ? false : { opacity: 0, x: -24 }} animate={{ opacity: 1, x: 0 }} transition={{ type: "spring", stiffness: 70, damping: 16 }}>
          <p className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700">
            <span className="h-1.5 w-1.5 rounded-full bg-blue-600" />
            For salons, clinics and studios
          </p>
          <h1 className="mt-5 text-4xl font-semibold tracking-tight sm:text-6xl">Your front desk, answering every message in seconds.</h1>
          <p className="mt-6 max-w-lg text-lg leading-relaxed text-slate-600">
            Deskhand replies to booking questions by text and web chat, checks your calendar and moves the appointment while your team keeps working.
          </p>
          <ul className="mt-7 space-y-3 text-slate-700">
            {points.map((p) => (
              <li key={p} className="flex items-start gap-3">
                <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="mt-0.5 h-5 w-5 flex-none text-blue-600">
                  <path fillRule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.7-9.3a1 1 0 0 0-1.4-1.4L9 10.6 7.7 9.3a1 1 0 0 0-1.4 1.4l2 2a1 1 0 0 0 1.4 0l4-4Z" clipRule="evenodd" />
                </svg>
                {p}
              </li>
            ))}
          </ul>
          <div className="mt-9 flex flex-col gap-3 sm:flex-row">
            <a href="#" className={`inline-flex items-center justify-center rounded-xl bg-blue-600 px-6 py-3.5 text-sm font-semibold text-white transition hover:bg-blue-700 ${ring}`}>Try it on your calendar</a>
            <a href="#" className={`inline-flex items-center justify-center rounded-xl px-6 py-3.5 text-sm font-semibold text-slate-900 ring-1 ring-inset ring-slate-300 transition hover:bg-slate-50 ${ring}`}>See pricing</a>
          </div>
        </motion.div>

        {/* The stage: a blue block with the conversation card floating on it. */}
        <motion.div
          className="relative rounded-3xl bg-gradient-to-br from-blue-600 to-indigo-700 p-5 sm:p-10"
          initial={reduce ? false : { opacity: 0, y: 40, scale: 0.96 }}
          animate={{ opacity: 1, y: 0, scale: 1 }}
          transition={{ type: "spring", stiffness: 70, damping: 16, delay: 0.1 }}
        >
          <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl">
            <div className="absolute -right-16 -top-16 h-64 w-64 rounded-full bg-sky-300/40 blur-3xl" />
            <div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:22px_22px]" />
          </div>

          <div className="shc-float relative">
            <div className="mx-auto max-w-sm overflow-hidden rounded-2xl bg-white shadow-2xl shadow-blue-950/50" role="img" aria-label="A demo conversation: a customer asks to move a Thursday haircut to Saturday, picks 2:15 pm, and receives a booking confirmation.">
              <div aria-hidden="true">
                <div className="flex items-center gap-3 border-b border-slate-100 px-4 py-3">
                  <span className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">AF</span>
                  <div className="min-w-0">
                    <p className="truncate text-sm font-semibold">Alder & Finch</p>
                    <p className="flex items-center gap-1.5 text-xs text-slate-500">
                      <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
                      Replies instantly
                    </p>
                  </div>
                </div>

                <div className="relative flex h-[29rem] flex-col justify-end overflow-hidden px-4 py-4 sm:h-[27rem]">
                  <p className={`absolute inset-x-0 top-3 text-center text-xs text-slate-400 transition-opacity duration-300 ${shown >= 4 ? "opacity-0" : "opacity-100"}`}>Today, 9:41 am</p>
                  <AnimatePresence mode="wait">
                    <motion.div key={loop} className="flex flex-col gap-2.5" exit={{ opacity: 0, y: -12 }} transition={{ duration: 0.3 }}>
                      <motion.p layout="position" className={mine}>{script.first}</motion.p>
                      {shown >= 1 && (
                        <motion.p layout="position" {...pop} className={theirs}>
                          <Words text={script.offer} still={reduce} />
                        </motion.p>
                      )}
                      {shown >= 2 && (
                        <motion.div layout="position" {...pop} className="flex origin-bottom-left gap-2">
                          {script.slots.map((s, i) => (
                            <span key={s} className={`rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors duration-300 ${i === 1 && shown >= 4 ? "border-blue-600 bg-blue-600 text-white" : "border-blue-200 bg-blue-50 text-blue-700"}`}>
                              {s}
                            </span>
                          ))}
                        </motion.div>
                      )}
                      {shown >= 4 && (
                        <motion.p layout="position" {...pop} className={mine}>{script.pick}</motion.p>
                      )}
                      {shown >= 6 && (
                        <motion.div layout="position" {...pop} className="mr-auto w-full max-w-[90%] origin-bottom-left">
                          <p className="rounded-2xl rounded-bl-md bg-slate-100 px-4 py-2.5 text-sm text-slate-800">
                            <Words text={script.done} still={reduce} />
                          </p>
                          <motion.div
                            initial={reduce ? false : { opacity: 0, y: 10 }}
                            animate={{ opacity: 1, y: 0 }}
                            transition={{ delay: 0.5, type: "spring", stiffness: 240, damping: 22 }}
                            className="mt-2 flex items-center gap-3 rounded-2xl border border-slate-200 bg-white p-3 shadow-sm"
                          >
                            <span className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-emerald-500 text-white">
                              <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
                                <motion.path d="M4.5 10.5l3.5 3.5 7.5-8" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ delay: 0.75, duration: 0.4 }} />
                              </svg>
                            </span>
                            <span className="min-w-0 text-sm">
                              <span className="block truncate font-semibold text-slate-900">{script.booking.when}</span>
                              <span className="block truncate text-xs text-slate-500">{script.booking.title}</span>
                            </span>
                          </motion.div>
                        </motion.div>
                      )}
                      {(shown === 0 || shown === 5) && (
                        <motion.div layout="position" {...pop} className="mr-auto origin-bottom-left rounded-2xl rounded-bl-md bg-slate-100 px-4 py-2">
                          <Dots />
                        </motion.div>
                      )}
                    </motion.div>
                  </AnimatePresence>
                </div>

                <div className="flex items-center gap-2 border-t border-slate-100 px-3 py-3">
                  <div className="flex h-10 min-w-0 flex-1 items-center rounded-full bg-slate-100 px-4 text-sm">
                    {shown === 3 ? (
                      <span className="truncate text-slate-900">
                        {script.pick.slice(0, typed)}
                        <span className="shc-caret ml-px inline-block h-4 w-px translate-y-0.5 bg-blue-600" />
                      </span>
                    ) : (
                      <span className="text-slate-400">Write a message</span>
                    )}
                  </div>
                  <span className={`flex h-10 w-10 flex-none items-center justify-center rounded-full text-white transition-all duration-200 ${shown === 3 && typed > 3 ? "scale-100 bg-blue-600" : "scale-90 bg-slate-300"}`}>
                    <svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
                      <path d="M3.1 3.3a.75.75 0 0 1 .8-.1l13 6.1a.75.75 0 0 1 0 1.4l-13 6.1a.75.75 0 0 1-1-.9L4.7 10 2.9 4.1a.75.75 0 0 1 .2-.8Z" />
                    </svg>
                  </span>
                </div>
              </div>
            </div>
          </div>
        </motion.div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/split-hero-with-self-playing-chat-demo.json

When to use it

How to customize it

Accessibility

Questions

How does the demo play by itself?

A single `step` number holds the current point in the conversation. An effect sets one timeout for the current step's hold time and then moves to the next step, and each bubble is rendered only once the step number reaches it. After the last step the list fades out and the counter returns to zero.

How do the older messages slide up when a new one appears?

The messages sit in a column that is pinned to the bottom of a fixed-height box. Each bubble has a layout animation, so when a new one is added underneath, the others glide to their new place instead of jumping.

How is the typing effect done?

For the message box, an interval raises a character count and the text is cut to that length, with a blinking caret after it. For the replies, each word is its own span that fades in slightly later than the one before.

Will the loop keep running in the background?

No. The timers are only set while the section is on screen, so the conversation waits where it is and continues when the visitor scrolls back.

Can I add more messages?

Yes. Add the text to `script`, add a hold time to `holds`, and render the new bubble with a `shown >= n` check in the same pattern as the existing ones.

Related: Hero with Orbiting App Icons Around a Logo, Split Hero with Email Capture, Hero with Live Updating Dashboard Mock.

More hero sections or all free UI components. Start for free to have a full site built around it.

More in Hero Sections

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap