Swish UI
All componentsNavigation and overlays Free

Nudge bubble

A chat launcher hops with a squash every few seconds, then grows a chat panel out of itself and turns into a close button.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

type Message = { from: "agent" | "me"; text: string };
const GREETING: Message = { from: "agent", text: "Hi, I am Noor from support. Want a hand setting up your first deploy?" };
const REPLIES: Record<string, string> = {
  "Yes, show me": "Great. Connect your repo and we will build every push. I sent the guide to your inbox.",
  "Just browsing": "No problem. I am here if anything comes up.",
};
// A springy hop with squash on landing, played on the launcher every few seconds while closed.
const NUDGE: Keyframe[] = [
  { transform: "translateY(0) scale(1, 1)" },
  { transform: "translateY(2px) scale(1.12, .86)", offset: 0.12 },
  { transform: "translateY(-16px) scale(.92, 1.1)", offset: 0.34 },
  { transform: "translateY(0) scale(1.14, .84)", offset: 0.56 },
  { transform: "translateY(-5px) scale(.97, 1.04)", offset: 0.72 },
  { transform: "translateY(0) scale(1.03, .97)", offset: 0.86 },
  { transform: "translateY(0) scale(1, 1)" },
];

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const bubble = useRef<HTMLButtonElement>(null);
  const panel = useRef<HTMLDivElement>(null);
  const log = useRef<HTMLDivElement>(null);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [open, setOpen] = useState(false);
  const [unread, setUnread] = useState(true);
  const [messages, setMessages] = useState<Message[]>([GREETING]);
  const [typing, setTyping] = useState(false);

  // Nudge the closed launcher every few seconds while it is on screen.
  useEffect(() => {
    const el = bubble.current;
    if (!el || open || reduced || !inView) return;
    let anim: Animation | undefined;
    const hop = () => { anim = el.animate(NUDGE, { duration: 760, easing: "ease-out" }); };
    const first = setTimeout(hop, 600);
    const timer = setInterval(hop, 3600);
    return () => { clearTimeout(first); clearInterval(timer); anim?.cancel(); };
  }, [open, reduced, inView]);

  // Opening moves focus into the panel; Escape closes it and returns focus to the launcher.
  useEffect(() => {
    if (!open) return;
    const target = panel.current?.querySelector<HTMLElement>("button[data-chip]") ?? panel.current;
    target?.focus({ preventScroll: true });
  }, [open]);

  // The agent replies after a short typing pause; the log keeps the newest message in view.
  useEffect(() => {
    const last = messages[messages.length - 1];
    if (log.current) log.current.scrollTop = log.current.scrollHeight;
    if (last.from !== "me") return;
    setTyping(true);
    const timer = setTimeout(() => {
      setTyping(false);
      setMessages((m) => [...m, { from: "agent", text: REPLIES[last.text] }]);
    }, reduced ? 300 : 1100);
    return () => clearTimeout(timer);
  }, [messages, reduced]);

  const toggle = () => { setOpen((o) => !o); setUnread(false); };
  const close = () => { setOpen(false); bubble.current?.focus(); };
  const answered = messages.some((m) => m.from === "me");

  return (
    <div ref={root} className={styles.root} onKeyDown={(e) => { if (e.key === "Escape" && open) close(); }}>
      <div className={styles.page} aria-hidden="true">
        <div className={styles.nav}><i /><span /><span /><span /></div>
        <p className={styles.headline}>Ship previews on every push</p>
        <span className={styles.line} />
        <span className={styles.line} />
        <span className={styles.cta} />
        <div className={styles.shot}><span /><span /><span /></div>
      </div>

      <div
        ref={panel}
        id="nb-panel"
        role="dialog"
        aria-label="Chat with support"
        className={styles.panel}
        tabIndex={-1}
        data-open={open || undefined}
        inert={!open}
      >
        <header className={styles.head}>
          <span className={styles.agent} aria-hidden="true">N</span>
          <div>
            <p className={styles.name}>Noor, Support</p>
            <p className={styles.sub}>Usually replies in 2 min</p>
          </div>
        </header>
        <div ref={log} className={styles.log} aria-live="polite">
          {messages.map((m, i) => <p key={i} className={styles.msg} data-from={m.from}>{m.text}</p>)}
          {typing && <p className={styles.typing} aria-label="Noor is typing"><i /><i /><i /></p>}
        </div>
        {!answered && (
          <div className={styles.chips}>
            {Object.keys(REPLIES).map((r) => (
              <button key={r} type="button" data-chip className={styles.chip} onClick={() => { panel.current?.focus(); setMessages((m) => [...m, { from: "me", text: r }]); }}>
                {r}
              </button>
            ))}
          </div>
        )}
      </div>

      <button
        ref={bubble}
        type="button"
        className={styles.bubble}
        data-open={open || undefined}
        aria-expanded={open}
        aria-controls="nb-panel"
        aria-label={open ? "Close chat" : unread ? "Open chat, 1 unread message" : "Open chat"}
        onClick={toggle}
      >
        <svg className={styles.chat} viewBox="0 0 24 24" aria-hidden="true">
          <path d="M5 5.5h14a1.5 1.5 0 0 1 1.5 1.5v8.5a1.5 1.5 0 0 1-1.5 1.5H10l-4.5 3.5V17H5a1.5 1.5 0 0 1-1.5-1.5V7A1.5 1.5 0 0 1 5 5.5z" />
          <path d="M8 10.5h.01M12 10.5h.01M16 10.5h.01" />
        </svg>
        <svg className={styles.x} viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" /></svg>
        {unread && <span className={styles.badge} aria-hidden="true">1</span>}
      </button>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; position: relative; width: 100%; height: 100%; overflow: hidden;
  box-sizing: border-box; background: var(--surface); font-family: system-ui, sans-serif;
}

/* A stand in product page behind the launcher. */
.page {
  position: absolute; inset: 12px; display: flex; flex-direction: column; gap: 10px; padding: 14px 18px; box-sizing: border-box;
  border-radius: 14px; border: 1px solid var(--line); background: var(--white); overflow: hidden;
}
.nav { display: flex; align-items: center; gap: 10px; margin-bottom: auto; }
.nav i { width: 18px; height: 18px; border-radius: 6px; background: var(--ink); margin-right: auto; }
.nav span { width: 34px; height: 6px; border-radius: 3px; background: var(--line); }
.headline { margin: 0; max-width: 16ch; font-size: clamp(18px, 7cqi, 34px); font-weight: 750; letter-spacing: -0.02em; line-height: 1.08; }
.line { height: 7px; width: min(260px, 60%); border-radius: 4px; background: var(--line); }
.line + .line { width: min(200px, 45%); }
/* Wide stages: a product shot on the right so the page reads as a real landing page. */
.shot {
  position: absolute; top: 56px; right: 8%; bottom: 40px; width: 34%; display: none; padding: 14px; box-sizing: border-box;
  border-radius: 12px; background: linear-gradient(160deg, var(--accent-pale), var(--sky)); border: 1px solid var(--line);
}
.shot span { display: block; height: 8px; margin-bottom: 8px; border-radius: 4px; background: var(--white); }
.shot span:nth-child(2) { width: 70%; }
.shot span:nth-child(3) { width: 45%; background: var(--accent); }
@container (min-width: 640px) { .shot { display: block; } }
.cta { width: 96px; height: 28px; margin-bottom: auto; border-radius: 8px; background: var(--accent); }

/* The launcher: chat icon and close icon swap with a turn, the badge pops away once read. */
.bubble {
  position: absolute; right: 22px; bottom: 22px; z-index: 2; display: grid; place-items: center; width: 48px; height: 48px; padding: 0;
  border: 0; border-radius: 50%; background: var(--ink); color: var(--white); cursor: pointer;
  box-shadow: 0 10px 22px -10px color-mix(in srgb, var(--ink) 70%, transparent);
  transform-origin: 50% 100%; transition: background-color .3s, border-radius .35s var(--out);
}
.bubble[data-open] { border-radius: 16px; background: var(--accent-text); }
.bubble:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 3px; }
.bubble svg { grid-area: 1 / 1; width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: rotate .4s var(--out), scale .4s var(--out), opacity .2s; }
.chat path:last-child { stroke-width: 2.6; }
.x { opacity: 0; rotate: -90deg; scale: .5; }
.bubble[data-open] .chat { opacity: 0; rotate: 90deg; scale: .5; }
.bubble[data-open] .x { opacity: 1; rotate: 0deg; scale: 1; }
.badge {
  position: absolute; top: -3px; right: -3px; display: grid; place-items: center; min-width: 19px; height: 19px; border-radius: 10px;
  background: var(--accent); color: var(--ink); font-size: 11px; font-weight: 800; box-shadow: 0 0 0 2px var(--white);
}

/* The panel grows out of the launcher: its transform-origin is the launcher's centre. */
.panel {
  position: absolute; right: 22px; bottom: 82px; z-index: 1; display: flex; flex-direction: column;
  width: min(290px, calc(100% - 44px)); height: min(330px, calc(100% - 100px)); box-sizing: border-box;
  border-radius: 16px; border: 1px solid var(--line); background: var(--white); overflow: hidden;
  box-shadow: 0 24px 48px -24px color-mix(in srgb, var(--ink) 55%, transparent);
  transform-origin: calc(100% - 24px) calc(100% + 36px);
  opacity: 0; scale: .15; visibility: hidden; pointer-events: none;
  transition: scale .3s var(--out), opacity .2s, visibility 0s .3s;
}
.panel[data-open] { opacity: 1; scale: 1; visibility: visible; pointer-events: auto; transition: scale .45s var(--out), opacity .2s, visibility 0s; }
.panel:focus { outline: none; }
.head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--ink); color: var(--white); }
.head p { margin: 0; }
.agent { display: grid; place-items: center; width: 30px; height: 30px; flex: none; border-radius: 50%; background: var(--accent-soft); color: var(--ink); font-size: 13px; font-weight: 750; }
.name { font-size: 13px; font-weight: 700; }
.sub { font-size: 11px; opacity: .7; }
.log { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; overflow-y: auto; }
.msg { margin: 0; max-width: 86%; padding: 7px 10px; border-radius: 12px 12px 12px 4px; background: var(--surface); font-size: 12.5px; line-height: 1.38; animation: msg .35s var(--out); }
.msg[data-from="me"] { align-self: flex-end; border-radius: 12px 12px 4px 12px; background: var(--accent-text); color: var(--white); }
@keyframes msg { from { opacity: 0; translate: 0 6px; } }
.typing { display: flex; gap: 4px; margin: 0; padding: 10px; width: max-content; border-radius: 12px; background: var(--surface); }
.typing i { width: 5px; height: 5px; border-radius: 50%; background: var(--muted); animation: dot 1s infinite ease-in-out; }
.typing i:nth-child(2) { animation-delay: .15s; }
.typing i:nth-child(3) { animation-delay: .3s; }
@keyframes dot { 40% { translate: 0 -3px; opacity: .5; } }
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px 12px; }
.chip {
  min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--accent-soft); background: var(--accent-pale);
  color: var(--accent-text); font: 600 12px system-ui, sans-serif; cursor: pointer; transition: transform .15s var(--out);
}
.chip:active { transform: scale(.95); }
.chip:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }

/* Short tiles: smaller header and tighter page so the panel keeps room for a message and the replies. */
@container (max-height: 320px) {
  .bubble { right: 22px; bottom: 22px; width: 44px; height: 44px; }
  .panel { right: 22px; bottom: 76px; width: min(270px, calc(100% - 44px)); height: calc(100% - 90px); transform-origin: calc(100% - 22px) calc(100% + 32px); }
  .head { padding: 6px 10px; }
  .agent { width: 24px; height: 24px; font-size: 11px; }
  .sub { display: none; }
  .log { padding: 8px 10px; }
  .chips { padding: 4px 10px 8px; }
  .chip { min-height: 30px; }
}

@media (prefers-reduced-motion: reduce) {
  .panel, .panel[data-open], .bubble svg { transition: opacity .15s, visibility 0s; scale: 1; rotate: 0deg; }
  .msg, .typing i { animation: none; }
}

Use it for

  • Support or sales chat launchers on product and pricing pages
  • Drawing the eye to one unread message without a modal
  • Corner widgets that open a small panel

Avoid

  • Nudging forever: stop once the visitor has opened it
  • Pages with another bottom corner widget, where two bounces compete

How it works

The launcher plays a Web Animations API keyframe list every few seconds: a crouch, a hop, a squash on landing and two small settles, all scale and translate on one element. The panel is always rendered and toggled with a data attribute; its transform-origin is set to the launcher's centre, so the scale transition grows it out of the bubble. The chat and close icons share one grid cell and swap with rotate, scale and opacity while the button's radius eases from a circle to a rounded square.

Accessibility

The launcher is a button with aria-expanded and a label that announces the unread message. Opening moves focus to the first quick reply, Escape closes and returns focus, the closed panel is inert, and new messages land in a polite live region. Reduced motion stops the nudge and fades the panel without scaling.

Found on