Chat Bubbles
Диалог с «печатает…»: переписка проигрывается как в настоящем мессенджере — три дышащие точки, пузырь снизу с наддувом, темп по сценарию. Стартует когда виден, играет один раз; под reduced-motion и data-shot переписка отдаётся целиком и сразу.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| messages? | ChatMessage[] | MESSAGES | |
| gap? | number | 500 | Пауза между сообщениями, мс. |
ChatMessage — что внутри
- from: "in" | "out"— Кто пишет: in — собеседник (слева), out — мы (справа).
- text: string
- typing?: number— Сколько «печатает» перед этим сообщением, мс.
Установка
npx shadcn@latest add https://motiva.pages.dev/r/chat-bubbles.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}