Bot
A chat assistant with a face: it listens, thinks and answers by changing shape.
Preview
"use client";
import { useState } from "react";
import { useAutoplay } from "../demo-context";
import { Bot, type BotState, type Message } from "./Bot";
const SCRIPT: { state: BotState; add?: Message }[] = [
{ state: "listening", add: { id: "1", from: "person", text: "Has my order shipped?" } },
{ state: "thinking" },
{ state: "talking", add: { id: "2", from: "bot", text: "It just left. It arrives tomorrow by 6 pm." } },
{ state: "done" },
{ state: "listening", add: { id: "3", from: "person", text: "Thanks!" } },
{ state: "done", add: { id: "4", from: "bot", text: "Anytime. Just call if you need anything." } },
{ state: "idle" },
];
export default function Demo() {
const [step, setStep] = useState(0);
useAutoplay(() => setStep((s) => (s + 1) % (SCRIPT.length + 1)), 1300);
const shown = SCRIPT.slice(0, step + 1);
const messages = shown.flatMap((s) => (s.add ? [s.add] : []));
const state = shown[shown.length - 1]?.state ?? "listening";
return <Bot state={state} messages={messages} name="Help" />;
}Installation
npx @usefluid/cli add botpnpm dlx @usefluid/cli add botyarn dlx @usefluid/cli add botbunx @usefluid/cli add botInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/bot/Bot.tsx "use client"; import type { ReactNode } from "react"; import { type Mood, Pet } from "../pet/Pet"; import styles from "./Bot.module.css"; export type BotState = "idle" | "listening" | "thinking" | "talking" | "done"; const MOOD: Record<BotState, Mood> = { idle: "idle", listening: "curious", thinking: "idle", talking: "idle", done: "happy" }; const LOOK: Record<BotState, { x: number; y: number }> = { idle: { x: 0, y: 0 }, listening: { x: -0.6, y: 0.4 }, thinking: { x: 0.6, y: -0.8 }, talking: { x: 0.4, y: 0 }, done: { x: 0, y: 0 }, }; const STATUS: Record<BotState, string> = { idle: "Ready", listening: "Listening", thinking: "Thinking", talking: "Answering", done: "Ready" }; export type Message = { id: string; from: "person" | "bot"; text: string }; type Props = { state: BotState; messages: Message[]; name?: string; you?: string; status?: Partial<Record<BotState, string>>; still?: boolean; color?: string; children?: ReactNode; }; export function Bot({ state, messages, name = "Assistant", you = "You", status, still = false, color, children }: Props) { const label = { ...STATUS, ...status }[state]; return ( <section className={styles.bot} aria-label={name}> <div className={styles.head}> <div className={styles.avatar} data-state={state}> <Pet mood={MOOD[state]} look={LOOK[state]} still={still} color={color} /> </div> <div> <p className={styles.name}>{name}</p> <p className={styles.status} aria-live="polite">{label}</p> </div> </div> <ol className={styles.messages} role="log" aria-live="polite"> {messages.map((m) => ( <li key={m.id} className={styles.message} data-from={m.from}> <span className={styles.srOnly}>{m.from === "person" ? you : name}: </span> {m.text} </li> ))} {state === "thinking" && ( <li className={styles.message} data-from="bot" aria-hidden> <span className={styles.dots}><i /><i /><i /></span> </li> )} </ol> {children} </section> ); }components/fluid/bot/Bot.module.css .bot { display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; width: 100%; max-width: 320px; min-width: 0; color: var(--fluid-text); font-family: var(--fluid-font); } .head { display: flex; align-items: center; gap: 10px; } .avatar { width: 52px; height: 52px; flex: none; } .name { margin: 0; font-size: 15px; font-weight: 600; } .status { margin: 0; color: var(--fluid-text-2); font-size: 13px; } .srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .messages { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; } .message { max-width: 85%; overflow-wrap: anywhere; padding: 9px 13px; border-radius: 18px; font-size: 14px; line-height: 1.35; animation: arrive 420ms cubic-bezier(0.34, 1.4, 0.64, 1) both; transform-origin: 0 100%; } .message[data-from="bot"] { align-self: flex-start; border-bottom-left-radius: 6px; background: var(--fluid-surface); border: 1px solid var(--fluid-border); } .message[data-from="person"] { align-self: flex-end; border-bottom-right-radius: 6px; background: var(--fluid-accent); color: var(--fluid-on-accent); transform-origin: 100% 100%; } @keyframes arrive { from { opacity: 0; transform: scale(0.6); filter: blur(4px); } to { opacity: 1; transform: none; filter: blur(0); } } .dots { display: inline-flex; gap: 4px; padding: 4px 0; } .dots i { width: 7px; height: 7px; border-radius: 999px; background: currentColor; opacity: 0.4; animation: dot 1s ease-in-out infinite; } .dots i:nth-child(2) { animation-delay: 0.15s; } .dots i:nth-child(3) { animation-delay: 0.3s; } @keyframes dot { 0%, 100% { transform: translateY(0); opacity: 0.4; } 40% { transform: translateY(-4px); opacity: 1; } } @media (prefers-reduced-motion: reduce) { .message, .dots i { animation: none; } }It uses these pieces too, so copy them alongside:
components/fluid/pet/Pet.tsx "use client"; import { springs } from "@usefluid/motion"; import { MorphPath, useReducedMotion, useSpring } from "@usefluid/motion/react"; import { useEffect, useId, useRef, useState } from "react"; import styles from "./Pet.module.css"; export type Mood = "idle" | "happy" | "surprised" | "sleepy" | "curious"; const TAU = Math.PI * 2; function body(w: number, h: number, round: number, cy = 70, lean = 0) { let d = ""; for (let i = 0; i < 120; i++) { const a = (i / 120) * TAU; const r = (Math.abs(Math.cos(a) / w) ** round + Math.abs(Math.sin(a) / h) ** round) ** (-1 / round); const y = cy + r * Math.sin(a); const x = 60 + r * Math.cos(a) + lean * ((cy - y) / h); d += `${i ? "L" : "M"}${x.toFixed(2)} ${y.toFixed(2)}`; } return `${d}Z`; } const BODIES: Record<Mood, string> = { idle: body(38, 36, 4.2), happy: body(42, 32, 4, 72), surprised: body(34, 41, 4.6, 66), sleepy: body(45, 28, 3.4, 78), curious: body(37, 37, 4.2, 70, 7), }; type Eye = { oval: [number, number]; line: [number, number] }; type Face = { left: Eye; right: Eye; eyeY: number; drop: number; tilt: number }; const open = (w: number, h: number): Eye => ({ oval: [w, h], line: [0, 0] }); const line = (w: number, bend: number): Eye => ({ oval: [0, 0], line: [w, bend] }); const FACES: Record<Mood, Face> = { idle: { left: open(4.5, 6.5), right: open(4.5, 6.5), eyeY: 68, drop: 0, tilt: 0 }, happy: { left: line(5.5, -5), right: line(5.5, -5), eyeY: 68, drop: -6, tilt: 0 }, surprised: { left: open(6.5, 8.5), right: open(6.5, 8.5), eyeY: 62, drop: -14, tilt: 0 }, sleepy: { left: line(6, 1.5), right: line(6, 1.5), eyeY: 78, drop: 10, tilt: 0 }, curious: { left: open(3.8, 5.5), right: open(6, 8), eyeY: 67, drop: -4, tilt: 6 }, }; function eyeAttrs(eye: Eye, cx: number, cy: number, size: number) { const lw = eye.line[0] * size; const lb = eye.line[1] * size; return { oval: { cx, cy, rx: eye.oval[0] * size, ry: eye.oval[1] * size }, line: { d: `M${cx - lw} ${cy} Q${cx} ${cy + lb * 1.6} ${cx + lw} ${cy}`, strokeWidth: lw > 0.4 ? 4.4 * size : 0 }, }; } export type Eyes = { x: number; y: number; gap?: number; size?: number }; type Watcher = (x: number, y: number, touch: boolean) => void; const watchers = new Set<Watcher>(); let pointer = { x: 0, y: 0, touch: false }; let frame = 0; const flush = () => { frame = 0; for (const watch of watchers) watch(pointer.x, pointer.y, pointer.touch); }; const onPointer = (e: PointerEvent) => { pointer = { x: e.clientX, y: e.clientY, touch: e.pointerType !== "mouse" }; if (!frame) frame = requestAnimationFrame(flush); }; function watch(fn: Watcher) { if (!watchers.size) { addEventListener("pointermove", onPointer, { passive: true }); addEventListener("pointerdown", onPointer, { passive: true }); } watchers.add(fn); return () => { watchers.delete(fn); if (watchers.size) return; removeEventListener("pointermove", onPointer); removeEventListener("pointerdown", onPointer); cancelAnimationFrame(frame); frame = 0; }; } function useVisible(ref: { current: Element | null }) { const [visible, setVisible] = useState(false); useEffect(() => { const el = ref.current; if (!el || typeof IntersectionObserver === "undefined") return setVisible(true); const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { rootMargin: "120px" }); io.observe(el); return () => io.disconnect(); }, [ref]); return visible; } const OBJECT_CURIOUS: Face = { left: open(4.4, 6.2), right: open(5.4, 7.6), eyeY: 66, drop: 0, tilt: 2 }; type Props = { mood?: Mood; shape?: string; eyes?: Eyes; drop?: boolean; still?: boolean; follow?: boolean; look?: { x: number; y: number }; label?: string; color?: string; className?: string; }; export function Pet({ mood = "idle", shape, eyes, drop = !shape, still = false, follow = false, look, label, color, className }: Props) { const reduced = useReducedMotion(); const mask = `pet-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`; const root = useRef<SVGSVGElement>(null); const parts = useRef<Record<string, SVGElement | null>>({}); const [gaze, setGaze] = useState({ x: 0, y: 0 }); const [blink, setBlink] = useState(false); const visible = useVisible(root); useEffect(() => { if (!follow || !visible) return; let back: ReturnType<typeof setTimeout> | undefined; const stop = watch((px, py, touch) => { clearTimeout(back); if (touch) back = setTimeout(() => setGaze({ x: 0, y: 0 }), 1200); const box = root.current?.getBoundingClientRect(); if (!box?.width) return; const dx = (px - (box.left + box.width / 2)) / (box.width * 2); const dy = (py - (box.top + box.height / 2)) / (box.height * 2); const x = Math.round(Math.max(-1, Math.min(1, dx)) * 10) / 10; const y = Math.round(Math.max(-1, Math.min(1, dy)) * 10) / 10; setGaze((g) => (g.x === x && g.y === y ? g : { x, y })); }); return () => { clearTimeout(back); stop(); }; }, [follow, visible]); useEffect(() => { if (reduced || still || !visible || mood === "sleepy") return; let open: ReturnType<typeof setTimeout>; const timer = setInterval(() => { setBlink(true); open = setTimeout(() => setBlink(false), 120); }, 3400); return () => { clearInterval(timer); clearTimeout(open); }; }, [reduced, still, visible, mood]); const f = shape && mood === "curious" ? OBJECT_CURIOUS : FACES[mood]; const g = follow ? gaze : (look ?? { x: 0, y: 0 }); const ex = eyes?.x ?? 60; const size = eyes?.size ?? 1; const gap = eyes?.gap ?? 13; const ey = (eyes?.y ?? 68) + (f.eyeY - 68) * size; const first = useRef({ l: eyeAttrs(f.left, ex + g.x * 6 * size - gap, ey + g.y * 4 * size - f.tilt * 0.4, size), r: eyeAttrs(f.right, ex + g.x * 6 * size + gap, ey + g.y * 4 * size + f.tilt * 0.4, size), }); useSpring( { lx: g.x * 6 * size, ly: g.y * 4 * size, ex, ey, gap, size, low: f.left.oval[0] * size, loh: (blink ? 0.8 : f.left.oval[1]) * size, llw: f.left.line[0] * size, llb: f.left.line[1] * size, row: f.right.oval[0] * size, roh: (blink ? 0.8 : f.right.oval[1]) * size, rlw: f.right.line[0] * size, rlb: f.right.line[1] * size, drop: f.drop, tilt: f.tilt, }, (v) => { const p = parts.current; const x = v.ex + v.lx; const y = v.ey + v.ly; const set = (el: SVGElement | null, attrs: Record<string, number | string>) => { if (el) for (const k in attrs) el.setAttribute(k, String(attrs[k])); }; const eye = (side: "l" | "r", cx: number, cy: number, ow: number, oh: number, lw: number, lb: number) => { set(p[`${side}Oval`], { cx, cy, rx: Math.max(0, ow), ry: Math.max(0, oh) }); set(p[`${side}Line`], { d: `M${cx - lw} ${cy} Q${cx} ${cy + lb * 1.6} ${cx + lw} ${cy}`, "stroke-width": lw > 0.4 ? 4.4 * v.size : 0, }); }; eye("l", x - v.gap, y - v.tilt * 0.4, v.low, v.loh, v.llw, v.llb); eye("r", x + v.gap, y + v.tilt * 0.4, v.row, v.roh, v.rlw, v.rlb); if (p.drop) p.drop.setAttribute("transform", `translate(0 ${v.drop}) rotate(${v.tilt * 2} 88 22)`); }, springs.snappy, ); const ref = (name: string) => (el: SVGElement | null) => { parts.current[name] = el; }; return ( <svg ref={root} viewBox="0 0 120 120" className={[styles.pet, className].filter(Boolean).join(" ")} style={color ? { color } : undefined} data-mood={mood} data-shape={shape ? "" : undefined} data-still={still || undefined} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true} > <defs> <mask id={mask} maskUnits="userSpaceOnUse" x="0" y="0" width="120" height="120"> <rect width="120" height="120" fill="#fff" /> <ellipse ref={ref("lOval")} fill="#000" {...first.current.l.oval} /> <ellipse ref={ref("rOval")} fill="#000" {...first.current.r.oval} /> <path ref={ref("lLine")} fill="none" stroke="#000" strokeLinecap="round" {...first.current.l.line} /> <path ref={ref("rLine")} fill="none" stroke="#000" strokeLinecap="round" {...first.current.r.line} /> </mask> </defs> <g className={styles.pose}> <g className={styles.body}> <MorphPath d={shape ?? BODIES[mood]} fill="currentColor" mask={`url(#${mask})`} config={{ w: 12, z: 0.72, points: 120, spread: 0.1 }} /> </g> </g> {drop && ( <g ref={ref("drop")}> <circle cx="88" cy="22" r="6" fill="currentColor" className={styles.drop} /> </g> )} </svg> ); }components/fluid/pet/Pet.module.css .pet { display: block; width: 100%; height: 100%; overflow: visible; color: var(--fluid-accent); } .body { transform-origin: 60px 108px; animation: breathe 3.2s ease-in-out infinite; } .pet[data-mood="happy"] .body { animation: hop 0.9s cubic-bezier(0.34, 1.56, 0.64, 1) 2, breathe 3.2s ease-in-out 1.8s infinite; } .pet[data-mood="sleepy"] .body { animation-duration: 4.8s; } .pose { transform-origin: 60px 104px; transition: transform 520ms cubic-bezier(0.34, 1.4, 0.64, 1); } .pet[data-shape][data-mood="sleepy"] .pose { transform: scale(1.05, 0.92); } .pet[data-shape][data-mood="surprised"] .pose { transform: translateY(-3px) scale(0.96, 1.05); } .pet[data-shape][data-mood="curious"] .pose { transform: rotate(-4deg); } .drop { transform-origin: 88px 22px; animation: float 2.4s ease-in-out infinite; } .pet[data-mood="sleepy"] .drop { animation-duration: 4s; } @keyframes breathe { 0%, 100% { transform: scale(1, 1); } 50% { transform: scale(1.03, 0.97); } } @keyframes hop { 0%, 100% { transform: translateY(0) scale(1, 1); } 30% { transform: translateY(-6px) scale(0.96, 1.05); } 60% { transform: translateY(0) scale(1.05, 0.95); } } @keyframes float { 0%, 100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-3px) scale(0.9); } } .pet[data-still] .body, .pet[data-still] .drop { animation: none; } @media (prefers-reduced-motion: reduce) { .body, .drop { animation: none; } .pose { transition: none; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { Bot, type BotState, type Message } from "./Bot";
<Bot state={state} messages={messages} name="Acme help">
<form onSubmit={send}>
<label htmlFor="ask">Ask a question</label>
<input id="ask" value={draft} onChange={(e) => { setDraft(e.target.value); setState("listening"); }} />
<button>Send</button>
</form>
</Bot>Colours
| What | How |
|---|---|
| The face | color prop: <Bot color="#ff5a1f" />. Without it, --fluid-accent. |
| Your messages | --fluid-accent for the bubble and --fluid-on-accent for its text. |
| The bot's messages | --fluid-surface, --fluid-border and --fluid-text. |
| Status line | --fluid-text-2. |
Set the variables on any parent to change one bot without touching the others:
<div style={{ "--fluid-accent": "#7c3aed", "--fluid-on-accent": "#ffffff" } as React.CSSProperties}>
<Bot state={state} messages={messages} />
</div>The eyes are holes cut out of the face, so they show the background behind it.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
state | "idle" | "listening" | "thinking" | "talking" | "done" | — | listening while the person types, thinking while waiting, talking when the answer arrives, then done or idle. |
messages | { id, from: "person" | "bot", text }[] | — | The conversation. |
name | string | "Assistant" | Shown and announced. |
you | string | "You" | How the person's messages are announced. |
status | Partial<Record<state, string>> | Ready, Listening... | Your own status words. |
still | boolean | false | No breathing or blinking. |
color | string | --fluid-accent | Colour of the face. |
children | ReactNode | — | Your input area, below the messages. |
Keyboard and screen readers
The conversation is a log: new messages are announced with who said them. The status line is announced too.
Notes
The bot has no input of its own; pass yours as children.
Every colour comes from the --fluid-* variables, and the piece is yours to change.
How it is made
The bot's face is the Pet. Each state of the conversation becomes a mood and a direction for the eyes: listening, it looks at your message; thinking, it looks up; answering, it looks at the reply. Messages arrive out of a blur, and the state goes to screen readers.
For your agent
For your agent
Add Bot to my project with the Fluid library. Use Bot in my product's support chat, wired to the real messages. Read my project first. Use my logo, my colours and my font through the --fluid-* variables. Keep 44px touch targets, visible focus and reduced motion. If I have no SVG logo, ask before making one up.