## Typing Indicator — Notification Someone is writing. Docs: https://www.interior.dev/docs/typing-indicator Reference: https://www.interior.dev/reference/typing-indicator License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/typing-indicator.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useEffect } from "react"; import { TypingIndicator, useTypingPresence, } from "@/components/interior/typing-indicator"; type Wire = | { kind: "key"; name: string } | { kind: "sent"; name: string } | { kind: "left"; name: string }; export function ThreadFooter({ roomId }: { roomId: string }) { const { typists, sending, ping, send, clear, reset } = useTypingPresence({ timeout: 3000, minVisible: 900, }); useEffect(() => { const socket = new WebSocket(`/rooms/${roomId}/presence`); socket.onmessage = (e) => { const msg = JSON.parse(e.data) as Wire; if (msg.kind === "key") ping(msg.name); else if (msg.kind === "sent") send(msg.name); else clear(msg.name); }; socket.onclose = reset; return () => socket.close(); }, [roomId, ping, send, clear, reset]); return (
); } ``` ### Props - `typists` (`string[]`) Names currently typing, in arrival order. An empty array leaves the row in place and empty rather than unmounting it. - `beat` (`number`) — default: `0` A monotonic count of keystroke events. Each increment strikes the next dot; it never advances on its own. - `sending` (`boolean`) — default: `false` True while the message is leaving. Drive it from the hook's `sending` and the bubble lifts away instead of collapsing back into its tail. - `size` (`number`) — default: `34` Bubble height in pixels. Width, dots, gaps and both tail knobs are drawn from it, so one number scales the whole thing. - `showLabel` (`boolean`) — default: `true` False leaves the bubble alone, for a thread where the name is already on the row. - `max` (`number`) — default: `2` Names printed before the label collapses to "and N others", so the sentence cannot grow without bound. - `announceAfter` (`number`) — default: `700` Quiet period before the sentence reaches the live region. Two people trading keystrokes is one announcement, not one per swap. - `send` (`(name: string) => void`) The ending most indicators forget. Releases the line, holds it on screen long enough to leave, then clears the room. - `className` (`string`) — default: `""` Appended last, so the row's height, gap and text size are all overridable from outside. ### Behavior notes - The shape is the one everybody already reads: a bubble, a tail, three dots. There is no reason to invent a new symbol for something a billion people learned years ago — what is worth changing is what makes it move. - Body and tail are one silhouette, not three objects hoping to touch. They are drawn into a single fill and overlap on purpose: the knob's centre sits 0.53·size from the corner arc's centre, well inside the 0.64·size where the two shapes stop meeting. Scale it to any size and there is still no seam. - The dots do not loop. The conventional indicator runs a 1.5s staggered animation on a timer, which means it keeps dancing over a socket that died ten seconds ago. Here one keystroke strikes one dot, so the rhythm you see is the rhythm someone is actually typing at, and silence looks like silence. - Typing has two endings and both are here. `send` lifts the bubble away as a message; silence collapses it back into its own tail, the way it arrived. An indicator that only ever fades treats a sent message and an abandoned one as the same event. - The dots never change what they do. There is one wave, one speed, one look, from the first keystroke to the last — and then the bubble goes. Settling the dots into a second resting state before they leave adds a beat nobody asked for and reads as a glitch rather than a state. - Nothing animates a layout property and nothing is measured. The bubble is drawn from one number, and every state — arriving, struck, settled, leaving — is opacity and transform, so the whole thing composites and React renders once per keystroke rather than once per frame. - The row reserves its full height whether or not anyone is typing, so a message list does not jump the moment presence arrives or expires. - Every ping carries its own expiry, so a peer who closes the tab mid-sentence stops the indicator on schedule instead of pinning "Nadia is typing" to the thread for the rest of the session. - A minimum visible duration holds the indicator up after the last key, so a one-word reply cannot flash the row on and off in the same second. When the room empties the beat returns to zero, so the next line starts from nothing rather than continuing someone else's. - The sentence is announced once from a polite live region after it settles, and the field is aria-hidden, so a hundred keystrokes never become a hundred screen-reader announcements. ### Source (`components/interior/typing-indicator.tsx`) ```tsx "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue, } from "motion/react"; const WAVE_MS = 1.25; const SURFACE = { type: "spring", stiffness: 380, damping: 30, mass: 0.8 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const EASE = [0.23, 1, 0.32, 1] as const; const LEAVE = [0.4, 0, 1, 1] as const; const INSTANT = { duration: 0 } as const; const SEND_MS = 340; export type UseTypingPresenceOptions = { timeout?: number; minVisible?: number; }; export type TypingPresence = { typists: string[]; beat: number; sending: boolean; ping: (name: string) => void; send: (name: string) => void; clear: (name: string) => void; reset: () => void; }; export function useTypingPresence({ timeout = 3000, minVisible = 900, }: UseTypingPresenceOptions = {}): TypingPresence { const [presence, setPresence] = useState<{ typists: string[]; beat: number }>({ typists: [], beat: 0, }); const [sending, setSending] = useState(false); const seen = useRef(new Map()); const shown = useRef([]); const shownAt = useRef(0); const sweep = useRef | null>(null); const release = useRef | null>(null); const settle = useRef<(bump: boolean) => void>(() => {}); const commit = useCallback( (bump: boolean) => { const now = Date.now(); for (const [name, at] of seen.current) { if (at + timeout <= now) seen.current.delete(name); } let next = Infinity; for (const at of seen.current.values()) next = Math.min(next, at + timeout); let roster = Array.from(seen.current.keys()); if (roster.length === 0 && shown.current.length > 0) { const until = shownAt.current + minVisible; if (until > now) { roster = shown.current; next = Math.min(next, until); } } const changed = roster.length !== shown.current.length || roster.some((name, i) => name !== shown.current[i]); if (changed) { if (shown.current.length === 0) shownAt.current = now; shown.current = roster; } if (changed || bump) { setPresence((prev) => ({ typists: changed ? roster : prev.typists, beat: changed && roster.length === 0 ? 0 : bump ? prev.beat + 1 : prev.beat, })); } if (sweep.current) clearTimeout(sweep.current); sweep.current = next === Infinity ? null : setTimeout(() => settle.current(false), Math.max(24, next - now)); }, [timeout, minVisible], ); settle.current = commit; const ping = useCallback( (name: string) => { if (release.current) { clearTimeout(release.current); release.current = null; setSending(false); shown.current = []; shownAt.current = 0; } seen.current.set(name, Date.now()); commit(true); }, [commit], ); const clear = useCallback( (name: string) => { if (!seen.current.delete(name)) return; commit(false); }, [commit], ); const send = useCallback((name: string) => { if (!seen.current.has(name) && !shown.current.includes(name)) return; seen.current.delete(name); if (sweep.current) clearTimeout(sweep.current); sweep.current = null; setSending(true); if (release.current) clearTimeout(release.current); release.current = setTimeout(() => { release.current = null; setSending(false); shown.current = []; shownAt.current = 0; setPresence({ typists: [], beat: 0 }); if (seen.current.size > 0) settle.current(false); }, SEND_MS); }, []); const reset = useCallback(() => { if (sweep.current) clearTimeout(sweep.current); if (release.current) clearTimeout(release.current); sweep.current = null; release.current = null; seen.current.clear(); shown.current = []; shownAt.current = 0; setSending(false); setPresence({ typists: [], beat: 0 }); }, []); useEffect(() => { return () => { if (sweep.current) clearTimeout(sweep.current); if (release.current) clearTimeout(release.current); sweep.current = null; release.current = null; }; }, []); return { typists: presence.typists, beat: presence.beat, sending, ping, send, clear, reset, }; } function describe(names: string[], max: number): string { if (names.length === 0) return ""; const head = names.slice(0, Math.max(1, max)); const rest = names.length - head.length; if (rest > 0) { return `${head.join(", ")} and ${rest} ${rest === 1 ? "other" : "others"} are typing`; } if (head.length === 1) return `${head[0]} is typing`; return `${head.slice(0, -1).join(", ")} and ${head[head.length - 1]} are typing`; } function Dot({ index, wave, size, }: { index: number; wave: MotionValue; size: number; }) { const lift = useTransform(wave, (w) => { let distance = (w - index) % 3; if (distance < 0) distance += 3; if (distance > 1.5) distance -= 3; return Math.max(0, 1 - Math.abs(distance)); }); const scale = useTransform(lift, [0, 1], [0.74, 1]); const opacity = useTransform(lift, [0, 1], [0.32, 1]); return ( ); } export type TypingIndicatorProps = { typists: string[]; sending?: boolean; max?: number; size?: number; showLabel?: boolean; announceAfter?: number; className?: string; }; export function TypingIndicator({ typists, sending = false, max = 2, size = 34, showLabel = true, announceAfter = 700, className = "", }: TypingIndicatorProps) { const reduced = useReducedMotion(); const label = useMemo(() => describe(typists, max), [typists, max]); const active = typists.length > 0; const wave = useMotionValue(0); useEffect(() => { if (!active || reduced) { wave.jump(0); return; } const controls = animate(wave, 3, { duration: WAVE_MS, ease: "linear", repeat: Infinity, repeatType: "loop", }); return () => controls.stop(); }, [active, reduced, wave]); const [announced, setAnnounced] = useState(label); useEffect(() => { const timer = setTimeout(() => setAnnounced(label), announceAfter); return () => clearTimeout(timer); }, [label, announceAfter]); const width = Math.round(size * 2); const dot = Math.round(size * 0.23); const gap = Math.round(size * 0.15); const radius = Math.round(size * 0.47); return (
{active ? ( {[0, 1, 2].map((i) => reduced ? ( ) : ( ), )} ) : null}
{showLabel ? ( {label && !sending ? ( {label} ) : null} ) : null} {announced}
); } ```