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
YesterdayToday
Saw your Hampi photos. Unreal light!
Sunrise at Matanga Hill. Worth the climb.
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?
No rush. Got us the window table.
Ordered you a cold brew too.
// 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.