Messenger

PreviousNext

Glassmorphism messenger workspace with accessible motion and quick replies

Docs
uitripledcomponent

Preview

Loading preview…
components/components/chat/messenger.tsx
"use client";

import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import {
  CheckCheck,
  MoreVertical,
  Paperclip,
  Phone,
  Search,
  Send,
  Video,
} from "lucide-react";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";

type Message = {
  id: string;
  sender: "user" | "contact";
  author: string;
  text: string;
  timestamp: string;
};

type Conversation = {
  id: string;
  name: string;
  title: string;
  status: "online" | "offline";
  unread: number;
  initials: string;
  messages: Message[];
  quickReplies: string[];
  autoReplies: string[];
};

const initialConversations: Conversation[] = [
  {
    id: "product-updates",
    name: "Morgan James",
    title: "Product Strategy",
    status: "online",
    unread: 2,
    initials: "MJ",
    messages: [
      {
        id: "product-1",
        sender: "contact",
        author: "Morgan",
        text: "Thanks for the recap on the CashFlow release. The beta cohort loves the motion work.",
        timestamp: "09:18",
      },
      {
        id: "product-2",
        sender: "user",
        author: "You",
        text: "Amazing to hear. Do we have a decision on the new Messenger surface?",
        timestamp: "09:21",
      },
      {
        id: "product-3",
        sender: "contact",
        author: "Morgan",
        text: "Almost there. Design asked for one more pass on the animation timings - soft ease on expand.",
        timestamp: "09:24",
      },
    ],
    quickReplies: [
      "I can share a timing proposal.",
      "Let me know if you need a demo recording.",
      "We can add an onboarding tooltip.",
    ],
    autoReplies: [
      "That would help so much - a short clip would be perfect.",
      "Can we confirm the handoff checklist by tomorrow?",
      "Love the attention to accessibility details here.",
    ],
  },
  {
    id: "customer-success",
    name: "Leah Patel",
    title: "Customer Success",
    status: "offline",
    unread: 0,
    initials: "LP",
    messages: [
      {
        id: "success-1",
        sender: "contact",
        author: "Leah",
        text: "Morning! Enterprise users keep mentioning how clear the summary cards feel.",
        timestamp: "08:05",
      },
      {
        id: "success-2",
        sender: "user",
        author: "You",
        text: "Great sign. Do we need any follow-up education for folks migrating from the legacy dashboard?",
        timestamp: "08:08",
      },
      {
        id: "success-3",
        sender: "contact",
        author: "Leah",
        text: "Maybe a guided walkthrough. I can draft the outline if you can provide the animation cues.",
        timestamp: "08:11",
      },
    ],
    quickReplies: [
      "Happy to add cues for the walkthrough.",
      "Let us sync after the support standup.",
      "We can capture a Loom covering the flows.",
    ],
    autoReplies: [
      "Perfect. Support will love a beat-by-beat cue list.",
      "Thanks - I will drop a doc in the shared folder shortly.",
      "Appreciate you keeping motion accessible throughout.",
    ],
  },
  {
    id: "engineering-handoff",
    name: "Build Squad",
    title: "Engineering Handoff",
    status: "offline",
    unread: 1,
    initials: "BS",
    messages: [
      {
        id: "build-1",
        sender: "contact",
        author: "Carson",
        text: "Do you have the reduced-motion variants for Messenger handy?",
        timestamp: "Yesterday",
      },
      {
        id: "build-2",
        sender: "user",
        author: "You",
        text: "Yep - exporting now with notes on keyboard focus states.",
        timestamp: "Yesterday",
      },
    ],
    quickReplies: [
      "Uploading the reduced-motion recording now.",
      "Focus ring spec is ready if you need it.",
      "We can pair on the final QA pass.",
    ],
    autoReplies: [
      "Legend - the team will plug that into the Storybook build tonight.",
      "Appreciate the extra detail on focus management.",
      "We will ping if anything else blocks us.",
    ],
  },
];

type ReplyCursorState = Record<string, number>;

export function Messenger() {
  const [selectedConversationId, setSelectedConversationId] = useState<string>(
    initialConversations[0]?.id ?? ""
  );
  const [conversations, setConversations] =
    useState<Conversation[]>(initialConversations);
  const [draft, setDraft] = useState("");
  const [replyCursor, setReplyCursor] = useState<ReplyCursorState>(() => {
    const cursor: ReplyCursorState = {};
    initialConversations.forEach((conversation) => {
      cursor[conversation.id] = 0;
    });
    return cursor;
  });
  const shouldReduceMotion = useReducedMotion();
  const messagesContainerRef = useRef<HTMLDivElement | null>(null);
  const liveRegionRef = useRef<HTMLDivElement | null>(null);
  const selectedConversationRef = useRef<string>(selectedConversationId);
  const replyTimeoutRef = useRef<number | null>(null);

  const activeConversation = useMemo(() => {
    return conversations.find(
      (conversation) => conversation.id === selectedConversationId
    );
  }, [conversations, selectedConversationId]);

  useEffect(() => {
    selectedConversationRef.current = selectedConversationId;
    setConversations((prev) =>
      prev.map((conversation) =>
        conversation.id === selectedConversationId
          ? { ...conversation, unread: 0 }
          : conversation
      )
    );
  }, [selectedConversationId]);

  useEffect(() => {
    if (!messagesContainerRef.current) {
      return;
    }
    const container = messagesContainerRef.current;
    const behavior = shouldReduceMotion ? "auto" : "smooth";

    const scrollToBottom = () => {
      container.scrollTo({ top: container.scrollHeight, behavior });
    };

    if (behavior === "smooth") {
      requestAnimationFrame(scrollToBottom);
    } else {
      scrollToBottom();
    }
  }, [
    activeConversation?.messages,
    activeConversation?.id,
    shouldReduceMotion,
  ]);

  useEffect(() => {
    return () => {
      if (replyTimeoutRef.current) {
        window.clearTimeout(replyTimeoutRef.current);
      }
    };
  }, []);

  useEffect(() => {
    if (!liveRegionRef.current || !activeConversation) {
      return;
    }
    const lastMessage =
      activeConversation.messages[activeConversation.messages.length - 1];
    if (!lastMessage) {
      return;
    }
    liveRegionRef.current.textContent =
      lastMessage.author +
      " at " +
      lastMessage.timestamp +
      ": " +
      lastMessage.text;
  }, [activeConversation?.messages, activeConversation]);

  const handleSelectConversation = (conversationId: string) => {
    setSelectedConversationId(conversationId);
  };

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!draft.trim() || !activeConversation) {
      return;
    }

    const conversationId = activeConversation.id;
    const timestamp = new Date().toLocaleTimeString("en-US", {
      hour: "2-digit",
      minute: "2-digit",
    });
    const outgoing: Message = {
      id: "outgoing-" + Date.now().toString(),
      sender: "user",
      author: "You",
      text: draft.trim(),
      timestamp,
    };

    setConversations((prev) =>
      prev.map((conversation) =>
        conversation.id === conversationId
          ? {
              ...conversation,
              messages: [...conversation.messages, outgoing],
              unread: 0,
            }
          : conversation
      )
    );
    setDraft("");

    const autoReplies = activeConversation.autoReplies;
    if (!autoReplies.length) {
      return;
    }

    const cursor = replyCursor[conversationId] ?? 0;
    const nextReply = autoReplies[cursor % autoReplies.length];
    const delay = shouldReduceMotion ? 0 : 900;

    replyTimeoutRef.current = window.setTimeout(() => {
      const safeTimestamp = new Date().toLocaleTimeString("en-US", {
        hour: "2-digit",
        minute: "2-digit",
      });
      const incoming: Message = {
        id: "incoming-" + Date.now().toString(),
        sender: "contact",
        author: activeConversation.name,
        text: nextReply,
        timestamp: safeTimestamp,
      };

      const currentSelected = selectedConversationRef.current;
      setConversations((prev) =>
        prev.map((conversation) => {
          if (conversation.id !== conversationId) {
            return conversation;
          }
          const isActive = currentSelected === conversationId;
          return {
            ...conversation,
            messages: [...conversation.messages, incoming],
            unread: isActive ? 0 : conversation.unread + 1,
          };
        })
      );

      setReplyCursor((prev) => ({
        ...prev,
        [conversationId]: cursor + 1,
      }));
    }, delay);
  };

  if (!activeConversation) {
    return null;
  }

  const statusDotColor: Record<Conversation["status"], string> = {
    online: "bg-green-500",
    offline: "bg-red-500",
  };

  return (
    <section className="relative w-full py-6 sm:py-8 md:py-12 lg:py-16">
      <div className="relative grid min-h-[500px] max-h-[calc(100vh-3rem)] grid-rows-[auto,1fr] gap-4 overflow-hidden rounded-[30px] border border-border/50 bg-background/70 p-4 backdrop-blur-xl sm:min-h-[600px] sm:max-h-[calc(100vh-4rem)] sm:gap-6 sm:p-6 md:min-h-[650px] md:max-h-[calc(100vh-5rem)] lg:h-[760px] lg:max-h-[calc(100vh-6rem)] lg:grid-rows-[1fr] lg:gap-8 lg:p-8 lg:[grid-template-columns:30%_1fr]">
        <div className="flex flex-col gap-3 rounded-2xl border border-border/40 bg-background/75 p-3 backdrop-blur sm:gap-4 sm:rounded-3xl sm:p-4 lg:hidden">
          <div className="flex items-center justify-between gap-2 sm:gap-3">
            <div>
              <p className="text-xs font-semibold text-foreground sm:text-sm">
                Messenger
              </p>
              <p className="text-[0.65rem] text-muted-foreground sm:text-xs">
                {conversations.length} active conversation
                {conversations.length === 1 ? "" : "s"}
              </p>
            </div>
            <Badge
              variant="outline"
              className="rounded-full border border-border/50 bg-primary/15 px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.2em] text-primary hover:bg-primary/15 hover:text-primary sm:px-3 sm:py-1 sm:text-[0.7rem] sm:tracking-[0.24em]"
            >
              Live
            </Badge>
          </div>
          <div className="space-y-1.5 sm:space-y-2">
            <label
              htmlFor="messenger-conversation"
              className="text-[0.65rem] font-medium text-muted-foreground sm:text-xs"
            >
              Conversation
            </label>
            <select
              id="messenger-conversation"
              value={selectedConversationId}
              onChange={(event) => handleSelectConversation(event.target.value)}
              className="w-full rounded-xl border border-border/40 bg-background/70 px-2.5 py-1.5 text-xs text-foreground focus:border-primary/40 focus:outline-none focus:ring-2 focus:ring-primary/30 sm:rounded-2xl sm:px-3 sm:py-2 sm:text-sm"
            >
              {conversations.map((conversation) => (
                <option key={conversation.id} value={conversation.id}>
                  {conversation.name}
                </option>
              ))}
            </select>
          </div>
        </div>

        <div className="hidden h-full flex-col gap-5 overflow-hidden rounded-3xl border border-border/40 bg-background/75 p-4 backdrop-blur lg:flex lg:col-start-1 lg:col-end-2">
          <div className="flex items-center justify-between gap-3">
            <div>
              <p className="text-sm font-semibold text-foreground">Messenger</p>
              <p className="text-xs text-muted-foreground">
                {conversations.length} active conversation
                {conversations.length === 1 ? "" : "s"}
              </p>
            </div>
            <Badge
              variant="outline"
              className="rounded-full border border-border/50 bg-primary/15 px-3 py-1 text-[0.7rem] uppercase tracking-[0.24em] text-primary hover:bg-primary/15 hover:text-primary"
            >
              Live
            </Badge>
          </div>

          <label htmlFor="messenger-search" className="sr-only">
            Search conversations
          </label>
          <div className="relative">
            <Search
              className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/70"
              aria-hidden="true"
            />
            <Input
              id="messenger-search"
              type="search"
              placeholder="Search teammates or channels"
              className="w-full rounded-2xl border-border/40 bg-background/60 pl-10 text-sm text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-2 focus-visible:ring-primary/40"
            />
          </div>

          <div
            className="flex-1 space-y-2 overflow-y-auto pr-1"
            aria-label="Conversation list"
            role="list"
          >
            {conversations.map((conversation) => {
              const isActive = conversation.id === selectedConversationId;
              const lastMessage =
                conversation.messages[conversation.messages.length - 1];
              return (
                <motion.button
                  key={conversation.id}
                  type="button"
                  onClick={() => handleSelectConversation(conversation.id)}
                  aria-pressed={isActive}
                  className={cn(
                    "group relative flex w-full items-start gap-3 rounded-2xl border border-transparent p-3 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                    isActive
                      ? "border-primary/40 bg-primary/10"
                      : "bg-background/70 hover:border-border/40 hover:bg-muted/40"
                  )}
                  role="listitem"
                >
                  <div className="relative shrink-0">
                    <Avatar className="h-10 w-10 rounded-2xl border border-border/40 bg-background/80 text-foreground">
                      <AvatarFallback className="rounded-2xl bg-primary/15 text-sm font-medium text-primary">
                        {conversation.initials}
                      </AvatarFallback>
                    </Avatar>
                    <span
                      className={cn(
                        "absolute bottom-0 right-0 inline-flex h-3 w-3 rounded-full border-2 border-background",
                        statusDotColor[conversation.status]
                      )}
                      aria-label={
                        conversation.status === "online" ? "Online" : "Offline"
                      }
                    />
                  </div>
                  <div className="min-w-0 flex-1 space-y-1">
                    <div className="flex flex-wrap items-center justify-between gap-2">
                      <div className="flex-1">
                        <p className="text-sm font-semibold text-foreground">
                          {conversation.name}
                        </p>
                        <p className="text-xs text-muted-foreground">
                          {conversation.title}
                        </p>
                      </div>
                    </div>
                    {lastMessage ? (
                      <p className="line-clamp-2 text-xs text-muted-foreground">
                        {lastMessage.author}: {lastMessage.text}
                      </p>
                    ) : (
                      <p className="text-xs text-muted-foreground">
                        No messages yet
                      </p>
                    )}
                  </div>
                  {conversation.unread > 0 && (
                    <span className="ml-2 inline-flex min-h-[1.5rem] min-w-[1.5rem] items-center justify-center rounded-full bg-primary text-[0.7rem] font-semibold text-primary-foreground shadow-lg">
                      {conversation.unread}
                    </span>
                  )}
                </motion.button>
              );
            })}
          </div>
        </div>

        <AnimatePresence initial={false} mode="wait">
          <motion.div
            key={activeConversation.id}
            initial={
              shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 12 }
            }
            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
            exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -12 }}
            transition={{ duration: 0.32, ease: "easeOut" }}
            className="flex min-h-0 flex-col gap-4 overflow-hidden rounded-3xl border border-border/40 bg-background/80 p-4 backdrop-blur sm:gap-5 sm:p-5 md:gap-6 md:p-6 lg:col-start-2 lg:col-end-3"
          >
            <header className="flex flex-wrap items-center justify-between gap-3 sm:gap-4">
              <div className="flex items-center gap-2 sm:gap-3">
                <div className="relative">
                  <Avatar className="h-10 w-10 rounded-2xl border border-border/40 bg-card/80 text-foreground sm:h-12 sm:w-12 sm:rounded-3xl">
                    <AvatarFallback className="rounded-2xl bg-primary/20 text-sm font-semibold text-primary sm:rounded-3xl sm:text-base">
                      {activeConversation.initials}
                    </AvatarFallback>
                  </Avatar>
                  <span
                    className={cn(
                      "absolute bottom-0 right-0 inline-flex h-3 w-3 rounded-full border-2 border-background sm:h-3.5 sm:w-3.5",
                      statusDotColor[activeConversation.status]
                    )}
                    aria-label={
                      activeConversation.status === "online"
                        ? "Online"
                        : "Offline"
                    }
                  />
                </div>
                <div>
                  <p className="text-sm font-semibold text-foreground sm:text-base">
                    {activeConversation.name}
                  </p>
                  <p className="text-xs text-muted-foreground sm:text-sm">
                    {activeConversation.title}
                  </p>
                </div>
              </div>

              <div className="flex items-center gap-1.5 sm:gap-2">
                <Button
                  type="button"
                  variant="ghost"
                  size="icon"
                  className="size-8 rounded-full border border-border/40 bg-background/60 text-muted-foreground transition hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:size-10"
                  aria-label="Start audio call"
                >
                  <Phone className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
                </Button>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon"
                  className="size-8 rounded-full border border-border/40 bg-background/60 text-muted-foreground transition hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:size-10"
                  aria-label="Start video call"
                >
                  <Video className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
                </Button>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon"
                  className="size-8 rounded-full border border-border/40 bg-background/60 text-muted-foreground transition hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:size-10"
                  aria-label="Open conversation menu"
                >
                  <MoreVertical className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
                </Button>
              </div>
            </header>

            <div
              ref={messagesContainerRef}
              className="relative flex-1 min-h-0 space-y-3 overflow-y-auto pr-2 sm:space-y-4 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted"
              aria-live="off"
              aria-label={"Message thread with " + activeConversation.name}
            >
              <AnimatePresence initial={false}>
                {activeConversation.messages.map((message) => (
                  <motion.div
                    key={message.id}
                    initial={
                      shouldReduceMotion
                        ? false
                        : { opacity: 0, y: 12, scale: 0.98 }
                    }
                    animate={
                      shouldReduceMotion
                        ? { opacity: 1 }
                        : { opacity: 1, y: 0, scale: 1 }
                    }
                    exit={{ opacity: 0, y: 0 }}
                    transition={{ duration: 0.28, ease: "easeOut" }}
                    className="flex flex-col gap-1"
                    role="group"
                    aria-label={message.author + " at " + message.timestamp}
                  >
                    <div
                      className={cn(
                        "relative max-w-[85%] rounded-xl border border-border/40 bg-background/80 px-3 py-2 text-xs leading-relaxed text-foreground backdrop-blur sm:max-w-[82%] sm:rounded-2xl sm:px-4 sm:py-3 sm:text-sm",
                        message.sender === "user" &&
                          "ml-auto border-primary/40 bg-primary text-primary-foreground"
                      )}
                    >
                      <p className="font-medium text-foreground/80 sm:text-sm">
                        {message.author}
                      </p>
                      <p
                        className={cn(
                          "mt-1 text-[0.875rem] sm:text-[0.95rem]",
                          message.sender === "user"
                            ? "text-primary-foreground/90"
                            : "text-foreground/90"
                        )}
                      >
                        {message.text}
                      </p>
                      <div className="mt-2 flex items-center justify-end gap-1.5 text-[0.65rem] sm:mt-3 sm:gap-2 sm:text-[0.7rem]">
                        <span
                          className={cn(
                            "text-muted-foreground",
                            message.sender === "user" &&
                              "text-primary-foreground/80"
                          )}
                        >
                          {message.timestamp}
                        </span>
                        {message.sender === "user" && (
                          <CheckCheck
                            className="h-3 w-3 text-primary-foreground/80 sm:h-3.5 sm:w-3.5"
                            aria-hidden="true"
                          />
                        )}
                      </div>
                    </div>
                  </motion.div>
                ))}
              </AnimatePresence>
            </div>

            <form
              onSubmit={handleSubmit}
              className="space-y-2 sm:space-y-3"
              aria-label="Reply composer"
            >
              <label htmlFor="messenger-editor" className="sr-only">
                Write a message
              </label>
              <div className="flex items-end gap-2 rounded-2xl border border-border/40 bg-background/80 p-3 backdrop-blur sm:gap-3 sm:rounded-3xl sm:p-4">
                <div className="flex-1 min-w-0">
                  <Textarea
                    id="messenger-editor"
                    value={draft}
                    onChange={(event) => setDraft(event.target.value)}
                    placeholder={"Message " + activeConversation.name}
                    rows={2}
                    className="min-h-[3rem] w-full resize-none border-none bg-transparent text-xs text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-0 focus-visible:outline-none sm:min-h-[4rem] sm:text-sm"
                    aria-label={"Message " + activeConversation.name}
                  />
                  <div className="mt-2 flex flex-wrap gap-1.5 sm:mt-3 sm:gap-2">
                    {activeConversation.quickReplies.map((reply) => (
                      <button
                        key={reply}
                        type="button"
                        onClick={() => setDraft(reply)}
                        className="rounded-full border border-border/50 bg-background/70 px-2.5 py-0.5 text-[0.65rem] text-muted-foreground transition hover:border-primary/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:px-3 sm:py-1 sm:text-xs"
                      >
                        {reply}
                      </button>
                    ))}
                  </div>
                </div>
                <div className="flex shrink-0 flex-col items-end gap-1.5 sm:w-24 sm:gap-2">
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon"
                    className="size-8 rounded-full border border-border/40 bg-background/70 text-muted-foreground transition hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:size-10"
                    aria-label="Attach a file"
                  >
                    <Paperclip className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
                  </Button>
                  <Button
                    type="submit"
                    size="icon"
                    className="size-8 rounded-full bg-primary text-primary-foreground shadow-lg transition hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-60 sm:size-10"
                    disabled={!draft.trim()}
                    aria-label="Send message"
                  >
                    <Send
                      className="h-3.5 w-3.5 sm:h-4 sm:w-4"
                      aria-hidden="true"
                    />
                  </Button>
                </div>
              </div>
            </form>
          </motion.div>
        </AnimatePresence>
      </div>
      <div
        ref={liveRegionRef}
        className="sr-only"
        aria-live="polite"
        aria-atomic="true"
      />
    </section>
  );
}

Installation

npx shadcn@latest add @uitripled/messenger

Usage

import { Messenger } from "@/components/messenger"
<Messenger />