Skip to content
All Taste components

Animated screens

Chat

A conversation where your message pops in, its ticks turn to read, and the reply arrives after typing dots.

pop-in bubbles · typing dots · read ticks · header status

KS
Kabir Shahonline
Yesterday

Saw your Hampi photos. Unreal light!

Sunrise at Matanga Hill. Worth the climb.

Today

Still on for lunch at Copper Kettle?

Yes! 1 pm works for me.

Great, I’ll head over a bit early.

Running 10 min late, save me a seat?

12:52

No rush. Got us the window table.

Ordered you a cold brew too.

Message
// Chat: your message pops in and its ticks go sent → delivered → read, the other side shows
// typing dots, then their replies spring in. Mobile-first on the Taste preset.
"use client";

import { useEffect, useRef, useState } from "react";
import { ChevronLeft, Phone, Plus, SendHorizontal, Video } from "lucide-react";
import { cn } from "@/lib/utils";

type Status = "sent" | "delivered" | "read";
type Msg = { id: number; me: boolean; text: string; time: string; status?: Status };

interface ChatProps {
  contact: { name: string; initials: string };
  initial: Msg[];
  /** Your transport. Report status changes and incoming replies through the callbacks. */
  send: (text: string, on: { status: (s: Status) => void; typing: (on: boolean) => void; reply: (text: string) => void }) => void;
}

const now = () => new Date().toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit", hour12: false });

export default function Chat({ contact, initial, send }: ChatProps) {
  const [msgs, setMsgs] = useState(initial);
  const [draft, setDraft] = useState("");
  const [typing, setTyping] = useState(false);
  const end = useRef<HTMLDivElement>(null);
  const ids = useRef(initial.length);

  useEffect(() => end.current?.scrollIntoView({ behavior: "smooth", block: "end" }), [msgs, typing]);

  function submit(e: React.FormEvent) {
    e.preventDefault();
    const text = draft.trim();
    if (!text) return;
    const id = ++ids.current;
    setMsgs((m) => [...m, { id, me: true, text, time: now(), status: "sent" }]);
    setDraft("");
    send(text, {
      status: (s) => setMsgs((m) => m.map((x) => (x.id === id ? { ...x, status: s } : x))),
      typing: setTyping,
      reply: (t) => {
        setTyping(false);
        setMsgs((m) => [...m, { id: ++ids.current, me: false, text: t, time: now() }]);
      },
    });
  }

  return (
    <main className="mx-auto flex h-dvh max-w-md flex-col bg-bg font-sans text-fg">
      <header className="flex h-14 shrink-0 items-center gap-1.5 border-b border-border px-1">
        <a href="/chats" aria-label="Back" className="grid size-10 place-items-center rounded-full hover:bg-surface-2"><ChevronLeft className="size-[22px]" /></a>
        <div className="flex min-w-0 flex-1 items-center gap-2.5">
          <span className="relative grid size-10 shrink-0 place-items-center rounded-full bg-warning-soft text-[13px] font-semibold text-warning-fg">
            {contact.initials}
            <span className="absolute bottom-0 right-0 size-2.5 rounded-full bg-success ring-2 ring-bg" />
          </span>
          <div className="min-w-0">
            <p className="truncate text-[14.5px] font-semibold leading-[18px]">{contact.name}</p>
            <p className={cn("text-xs", typing ? "text-primary" : "text-fg-muted")} aria-live="polite">{typing ? "typing…" : "online"}</p>
          </div>
        </div>
        <button type="button" aria-label="Video call" className="grid size-10 place-items-center rounded-full hover:bg-surface-2"><Video className="size-5" /></button>
        <button type="button" aria-label="Call" className="grid size-10 place-items-center rounded-full hover:bg-surface-2"><Phone className="size-[19px]" /></button>
      </header>

      <div role="log" aria-label="Messages" className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-surface px-3 pb-2.5">
        <div className="mt-auto" />
        {msgs.map((m, i) => (
          <div key={m.id} className={cn("flex flex-col", m.me ? "items-end" : "items-start")}>
            <p
              className={cn(
                "mt-1.5 max-w-[80%] px-3 py-2 text-[13.5px] leading-[18px] animate-[bubble-pop_.4s_cubic-bezier(.34,1.56,.64,1)_both] motion-reduce:animate-none",
                m.me ? "origin-bottom-right rounded-[18px] rounded-br-md bg-primary text-primary-fg" : "origin-bottom-left rounded-[18px] rounded-bl-md bg-bg ring-1 ring-border",
              )}
              style={i < initial.length ? { animation: "none" } : undefined}
            >
              {m.text}
            </p>
            {m.me && m.status && (
              <p className="mt-[3px] flex items-center gap-[3px] text-[10.5px] text-fg-subtle">
                <span className="tabular-nums">{m.time}</span>
                <svg width="16" height="11" viewBox="0 0 16 11" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-label={m.status} className={cn("transition-colors duration-300", m.status === "read" ? "text-primary" : "text-fg-subtle")}>
                  <path d="m1 6 3 3 6-7" />
                  {m.status !== "sent" && <path d="m7 8.5.5.5 6-7" className="animate-fade motion-reduce:animate-none" />}
                </svg>
              </p>
            )}
          </div>
        ))}
        {typing && (
          <span aria-hidden className="mt-1.5 inline-flex gap-1 self-start rounded-[18px] rounded-bl-md bg-bg px-3.5 py-3 ring-1 ring-border animate-pop motion-reduce:animate-none">
            {[0, 1, 2].map((d) => (
              <i key={d} className="size-[7px] rounded-full bg-fg-subtle animate-[typing_1.2s_cubic-bezier(.2,.7,.1,1)_infinite] motion-reduce:animate-none" style={{ animationDelay: d * 150 + "ms" }} />
            ))}
          </span>
        )}
        <div ref={end} />
      </div>

      <form onSubmit={submit} className="flex shrink-0 items-center gap-2 border-t border-border bg-bg px-2.5 pb-6 pt-2">
        <button type="button" aria-label="Attach" className="grid size-[38px] place-items-center rounded-full hover:bg-surface-2"><Plus className="size-[22px]" /></button>
        <input
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          placeholder="Message"
          aria-label="Message"
          className="h-[38px] min-w-0 flex-1 rounded-full border border-border-strong bg-bg px-3.5 text-[13.5px] outline-none placeholder:text-fg-subtle focus:border-primary"
        />
        <button type="submit" aria-label="Send" className="grid size-[38px] place-items-center rounded-full bg-primary text-primary-fg transition active:scale-90 motion-reduce:transition-none">
          <SendHorizontal className="size-[17px]" />
        </button>
      </form>
    </main>
  );
}

/* globals.css
@keyframes bubble-pop { from { opacity: 0; transform: scale(.6); } }
@keyframes typing { 0%, 60%, 100% { transform: none; opacity: .45; } 30% { transform: translateY(-3px); opacity: 1; } }
*/

The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.