Motiva
Мои коллекции
← каталог

Chat Bubbles

Диалог с «печатает…»: переписка проигрывается как в настоящем мессенджере — три дышащие точки, пузырь снизу с наддувом, темп по сценарию. Стартует когда виден, играет один раз; под reduced-motion и data-shot переписка отдаётся целиком и сразу.

cssautorevealstaggerbasic~3 КБreduced-motion ✓
↗
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
messages?ChatMessage[]MESSAGES
gap?number500Пауза между сообщениями, мс.
ChatMessage — что внутри
  • from: "in" | "out"— Кто пишет: in — собеседник (слева), out — мы (справа).
  • text: string
  • typing?: number— Сколько «печатает» перед этим сообщением, мс.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/chat-bubbles.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef, useState } from "react";
const css = `.chb {
  display: grid;
  gap: 10px;
  width: min(100%, 420px);
  font-family: var(--ds-font, system-ui, sans-serif);
}

.chb-row { display: flex; }
.chb-row[data-from="out"] { justify-content: flex-end; }

.chb-b {
  max-width: 82%;
  padding: 10px 14px;
  border-radius: 16px;
  font-size: var(--ds-text-sm, 0.92rem);
  line-height: 1.45;
  background: var(--ds-surface, oklch(0.19 0.02 273));
  color: var(--ds-fg, oklch(0.95 0.01 265));
  border: 1px solid var(--ds-border, oklch(0.3 0.02 275));
  border-bottom-left-radius: 6px;
}
.chb-row[data-from="out"] .chb-b {
  background: var(--ds-accent, oklch(0.72 0.16 300));
  color: var(--ds-accent-fg, oklch(0.14 0.02 275));
  border-color: transparent;
  border-bottom-left-radius: 16px;
  border-bottom-right-radius: 6px;
}

/* появление пузыря: снизу и чуть с наддувом — как в настоящих мессенджерах */
@media not (prefers-reduced-motion: reduce) {
  .chb-row[data-live] {
    animation: chb-in 0.35s var(--ds-ease-out, cubic-bezier(0.34, 1.4, 0.44, 1)) both;
  }
  @keyframes chb-in {
    from { opacity: 0; transform: translateY(10px) scale(0.96); }
  }
}
html[data-shot] .chb-row { animation: none; }

/* «печатает…»: три точки дышат по очереди */
.chb-dots { display: inline-flex; gap: 4px; padding: 3px 2px; }
.chb-dots i {
  width: 6px; height: 6px; border-radius: 50%;
  background: currentColor; opacity: 0.35;
}
@media not (prefers-reduced-motion: reduce) {
  .chb-dots i { animation: chb-dot 1s ease-in-out infinite; }
  .chb-dots i:nth-child(2) { animation-delay: 0.16s; }
  .chb-dots i:nth-child(3) { animation-delay: 0.32s; }
  @keyframes chb-dot {
    30% { opacity: 1; transform: translateY(-3px); }
  }
}
`;

export interface ChatMessage {
  /** Кто пишет: in — собеседник (слева), out — мы (справа). */
  from: "in" | "out";
  text: string;
  /** Сколько «печатает» перед этим сообщением, мс. */
  typing?: number;
}

export interface ChatBubblesProps {
  messages?: ChatMessage[];
  /** Пауза между сообщениями, мс. */
  gap?: number;
}

const MESSAGES: ChatMessage[] = [
  { from: "in", text: "Привет! Подскажете, бот умеет напоминать про оплату?", typing: 900 },
  { from: "out", text: "Умеет — и мягко: сначала за три дня, потом в день платежа.", typing: 1400 },
  { from: "in", text: "А если клиент так и не заплатил?", typing: 900 },
  { from: "out", text: "Поставит задачу менеджеру и приложит всю переписку. Без «вы всё ещё должны…» в полночь.", typing: 1600 },
  { from: "in", text: "Забираю. Куда платить?", typing: 700 },
];

/**
 * ДИАЛОГ С «ПЕЧАТАЕТ…» — переписка проигрывается как в настоящем мессенджере:
 * три дышащие точки, потом пузырь снизу с лёгким наддувом. Для лендингов ботов
 * и поддержки такой диалог — честнее скриншота: видно ТЕМП разговора.
 *
 * Дисциплина: сценарий стартует, только когда блок ВИДЕН (IntersectionObserver),
 * играет один раз, все таймеры собираются и снимаются. Под reduced-motion
 * и data-shot переписка отдаётся ЦЕЛИКОМ и сразу — темп это украшение,
 * а содержание принадлежит всем. Для читалок то же самое: разметка полная
 * с первого рендера у reduce-пользователей, у остальных — появляется по ходу
 * (без aria-live: чужой диалог не должен звучать как уведомления).
 */
export function ChatBubbles({ messages = MESSAGES, gap = 500 }: ChatBubblesProps) {
  const host = useRef<HTMLDivElement>(null);
  // сколько сообщений уже на экране; -1 = «показать всё сразу» (reduce/shot)
  const [shown, setShown] = useState(0);
  const [typing, setTyping] = useState<number | null>(null);

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    if (
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ) {
      setShown(messages.length);
      return;
    }

    const timers: ReturnType<typeof setTimeout>[] = [];
    let cancelled = false;

    const play = (i: number) => {
      if (cancelled || i >= messages.length) { setTyping(null); return; }
      const t = messages[i]!.typing ?? 1000;
      setTyping(i);
      timers.push(
        setTimeout(() => {
          if (cancelled) return;
          setTyping(null);
          setShown(i + 1);
          timers.push(setTimeout(() => play(i + 1), gap));
        }, t),
      );
    };

    const io = new IntersectionObserver(
      ([e]) => {
        if (e?.isIntersecting) {
          io.disconnect();
          timers.push(setTimeout(() => play(0), 400));
        }
      },
      { threshold: 0.4 },
    );
    io.observe(el);
    return () => {
      cancelled = true;
      io.disconnect();
      timers.forEach(clearTimeout);
    };
  }, [messages, gap]);

  return (
    <div ref={host} className="chb" role="log" aria-label="Пример диалога">
      <style href="motiva-chat-bubbles" precedence="medium">{css}</style>
      {messages.slice(0, shown).map((m, i) => (
        <div key={i} className="chb-row" data-from={m.from} data-live={i === shown - 1 ? "" : undefined}>
          <div className="chb-b">{m.text}</div>
        </div>
      ))}
      {typing !== null && (
        <div className="chb-row" data-from={messages[typing]!.from} data-live="">
          <div className="chb-b" aria-hidden="true">
            <span className="chb-dots"><i /><i /><i /></span>
          </div>
        </div>
      )}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva)v1.0.0