Web InnoventixFreeCode

Chat Header

Original · free

chat window header with actions

byWeb InnoventixReact + Tailwind
chatheader
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/chat-header.json
chat-header.tsx
"use client";

import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Presence = "online" | "busy" | "away" | "offline";
type CallKind = "voice" | "video";

type Member = {
  id: string;
  name: string;
  role: string;
  initials: string;
  presence: Presence;
  ring: string;
};

type Message = {
  id: string;
  authorId: string;
  text: string;
  time: string;
};

const MEMBERS: Member[] = [
  {
    id: "priya",
    name: "Priya Raman",
    role: "Staff engineer",
    initials: "PR",
    presence: "online",
    ring: "from-indigo-500 to-violet-600",
  },
  {
    id: "dane",
    name: "Dane Okonkwo",
    role: "Product design",
    initials: "DO",
    presence: "busy",
    ring: "from-emerald-500 to-sky-600",
  },
  {
    id: "lena",
    name: "Lena Fischer",
    role: "Payments lead",
    initials: "LF",
    presence: "away",
    ring: "from-amber-500 to-rose-500",
  },
  {
    id: "tom",
    name: "Tomás Ferreira",
    role: "QA",
    initials: "TF",
    presence: "offline",
    ring: "from-slate-500 to-zinc-600",
  },
];

const MESSAGES: Message[] = [
  {
    id: "m1",
    authorId: "priya",
    text: "Staging is live with the one-page checkout. Card, Apple Pay and iDEAL all clear the sandbox now.",
    time: "09:41",
  },
  {
    id: "m2",
    authorId: "dane",
    text: "Ran it on a 5-year-old Android. The step indicator no longer wraps to two lines — that was the last blocker on my list.",
    time: "09:44",
  },
  {
    id: "m3",
    authorId: "lena",
    text: "Refund path still returns a 402 when the original charge was split across two payment methods. I logged it as PAY-318.",
    time: "09:52",
  },
  {
    id: "m4",
    authorId: "priya",
    text: "Looking at PAY-318 now. It's the reconciliation job reading only the first charge id — small fix, big blast radius.",
    time: "09:55",
  },
  {
    id: "m5",
    authorId: "tom",
    text: "Regression suite: 214 passing, 2 skipped. The skips are the split-refund cases, so they'll go green with Priya's patch.",
    time: "10:03",
  },
  {
    id: "m6",
    authorId: "dane",
    text: "Then I think we ship behind the flag on Thursday and ramp to 10% of EU traffic Friday morning.",
    time: "10:07",
  },
];

const PRESENCE_DOT: Record<Presence, string> = {
  online: "bg-emerald-500",
  busy: "bg-rose-500",
  away: "bg-amber-500",
  offline: "bg-slate-400 dark:bg-slate-600",
};

const PRESENCE_LABEL: Record<Presence, string> = {
  online: "Online",
  busy: "In a meeting",
  away: "Away",
  offline: "Offline",
};

function byId(id: string): Member {
  return MEMBERS.find((m) => m.id === id) ?? MEMBERS[0];
}

function formatClock(total: number): string {
  const mm = Math.floor(total / 60)
    .toString()
    .padStart(2, "0");
  const ss = (total % 60).toString().padStart(2, "0");
  return `${mm}:${ss}`;
}

function highlight(text: string, query: string): ReactNode {
  const needle = query.trim().toLowerCase();
  if (!needle) return text;
  const hay = text.toLowerCase();
  const out: ReactNode[] = [];
  let cursor = 0;
  let key = 0;
  for (;;) {
    const at = hay.indexOf(needle, cursor);
    if (at === -1) {
      out.push(text.slice(cursor));
      break;
    }
    if (at > cursor) out.push(text.slice(cursor, at));
    out.push(
      <mark
        key={`hl-${key++}`}
        className="rounded-[3px] bg-amber-200 px-0.5 text-slate-900 dark:bg-amber-300/80 dark:text-slate-900"
      >
        {text.slice(at, at + needle.length)}
      </mark>,
    );
    cursor = at + needle.length;
  }
  return out;
}

const ICON = "h-[18px] w-[18px]";

function IconSearch() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
      <circle cx="11" cy="11" r="6.5" stroke="currentColor" strokeWidth="1.7" />
      <path d="m16 16 4.5 4.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
    </svg>
  );
}

function IconPhone() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
      <path
        d="M6.2 3.8h3l1.4 3.6-2 1.5a11.4 11.4 0 0 0 4.9 4.9l1.5-2 3.6 1.4v3a1.8 1.8 0 0 1-2 1.8C10.3 17.5 6 13.2 4.4 5.8a1.8 1.8 0 0 1 1.8-2Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconVideo() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
      <rect x="3" y="6.5" width="11.5" height="11" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
      <path
        d="m15 11 4.4-2.7c.6-.4 1.4 0 1.4.8v5.8c0 .8-.8 1.2-1.4.8L15 13z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function IconInfo() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
      <circle cx="12" cy="12" r="8.6" stroke="currentColor" strokeWidth="1.6" />
      <path d="M12 11v5.2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
      <circle cx="12" cy="7.9" r="1.05" fill="currentColor" />
    </svg>
  );
}

function IconDots() {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={ICON}>
      <circle cx="12" cy="5.5" r="1.7" />
      <circle cx="12" cy="12" r="1.7" />
      <circle cx="12" cy="18.5" r="1.7" />
    </svg>
  );
}

function IconClose() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
      <path d="m6.5 6.5 11 11m0-11-11 11" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
    </svg>
  );
}

function IconBellOff() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
      <path
        d="M8.2 6.1A4.8 4.8 0 0 1 16.8 9v3.3l1.6 3.2H7.4m-1.2-2.6.8-1.6V9c0-.5.1-1 .2-1.5"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path d="M10.2 18.6a2 2 0 0 0 3.6 0M4 4l16 16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function IconPin() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
      <path
        d="M14.4 3.6 20.4 9.6l-2.3.5a3 3 0 0 0-1.6.9l-2.3 2.4.7 3.2-6-6 3.2.7 2.4-2.3a3 3 0 0 0 .9-1.6z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
      <path d="M8.9 15.1 4.5 19.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function IconCheck() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
      <path d="m5 12.5 4.2 4.2L19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function IconHangUp() {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={ICON}>
      <path
        d="M3.6 13.4c4.6-4.4 12.2-4.4 16.8 0 .8.8.7 1.7 0 2.4l-1.6 1.5c-.6.6-1.4.5-2-.1l-1.3-1.5c-.3-.4-.4-.6-.4-1.2v-1c-2.3-.7-4.5-.7-6.8 0v1c0 .6-.1.8-.4 1.2l-1.3 1.5c-.6.6-1.4.7-2 .1l-1.6-1.5c-.7-.7-.8-1.6.6-2.4Z"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinejoin="round"
      />
    </svg>
  );
}

const iconBtn =
  "inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-950";

const iconBtnOn =
  "inline-flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 transition-colors hover:bg-indigo-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25 dark:focus-visible:ring-offset-slate-950";

type MenuEntry = {
  id: string;
  label: string;
  checkbox: boolean;
  checked?: boolean;
  danger?: boolean;
  run: () => void;
};

export default function ChatHeader() {
  const reduce = useReducedMotion();
  const uid = useId();
  const menuId = `${uid}-menu`;
  const searchId = `${uid}-search`;
  const detailsId = `${uid}-details`;

  const [searchOpen, setSearchOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [menuOpen, setMenuOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const [detailsOpen, setDetailsOpen] = useState(false);
  const [muted, setMuted] = useState(false);
  const [pinned, setPinned] = useState(true);
  const [unread, setUnread] = useState(false);
  const [call, setCall] = useState<CallKind | null>(null);
  const [micOff, setMicOff] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const [announce, setAnnounce] = useState("");

  const menuBtnRef = useRef<HTMLButtonElement | null>(null);
  const searchBtnRef = useRef<HTMLButtonElement | null>(null);
  const detailsBtnRef = useRef<HTMLButtonElement | null>(null);
  const searchInputRef = useRef<HTMLInputElement | null>(null);
  const menuWrapRef = useRef<HTMLDivElement | null>(null);
  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const online = MEMBERS.filter((m) => m.presence === "online" || m.presence === "busy").length;

  const results = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return MESSAGES;
    return MESSAGES.filter(
      (m) => m.text.toLowerCase().includes(q) || byId(m.authorId).name.toLowerCase().includes(q),
    );
  }, [query]);

  useEffect(() => {
    if (call === null) return;
    const timer = window.setInterval(() => setElapsed((s) => s + 1), 1000);
    return () => window.clearInterval(timer);
  }, [call]);

  useEffect(() => {
    if (!menuOpen) return;
    itemRefs.current[activeIndex]?.focus();
  }, [menuOpen, activeIndex]);

  useEffect(() => {
    if (!menuOpen) return;
    const onPointer = (event: PointerEvent) => {
      if (!menuWrapRef.current) return;
      if (event.target instanceof Node && !menuWrapRef.current.contains(event.target)) {
        setMenuOpen(false);
      }
    };
    document.addEventListener("pointerdown", onPointer);
    return () => document.removeEventListener("pointerdown", onPointer);
  }, [menuOpen]);

  const startCall = (kind: CallKind) => {
    setElapsed(0);
    setMicOff(false);
    setCall(kind);
    setMenuOpen(false);
    setAnnounce(`${kind === "video" ? "Video" : "Voice"} call started with Checkout rebuild`);
  };

  const endCall = () => {
    setAnnounce(`Call ended after ${formatClock(elapsed)}`);
    setCall(null);
  };

  const openSearch = () => {
    setSearchOpen(true);
    setMenuOpen(false);
    window.setTimeout(() => searchInputRef.current?.focus(), 20);
  };

  const closeSearch = () => {
    setSearchOpen(false);
    setQuery("");
    searchBtnRef.current?.focus();
  };

  const toggleDetails = () => {
    const next = !detailsOpen;
    setDetailsOpen(next);
    setAnnounce(next ? "Chat details opened" : "Chat details closed");
  };

  const entries: MenuEntry[] = [
    {
      id: "video",
      label: "Start video call",
      checkbox: false,
      run: () => startCall("video"),
    },
    {
      id: "details",
      label: "Chat details",
      checkbox: false,
      run: () => {
        setMenuOpen(false);
        setDetailsOpen(true);
        setAnnounce("Chat details opened");
      },
    },
    {
      id: "mute",
      label: "Mute notifications",
      checkbox: true,
      checked: muted,
      run: () => {
        setMuted(!muted);
        setAnnounce(muted ? "Notifications unmuted" : "Notifications muted");
      },
    },
    {
      id: "pin",
      label: "Pin to top of inbox",
      checkbox: true,
      checked: pinned,
      run: () => {
        setPinned(!pinned);
        setAnnounce(pinned ? "Conversation unpinned" : "Conversation pinned");
      },
    },
    {
      id: "unread",
      label: "Mark as unread",
      checkbox: false,
      run: () => {
        setUnread(true);
        setMenuOpen(false);
        setAnnounce("Conversation marked as unread");
      },
    },
    {
      id: "export",
      label: "Export transcript (.txt)",
      checkbox: false,
      run: () => {
        setMenuOpen(false);
        setAnnounce(`Transcript queued for export, ${MESSAGES.length} messages`);
      },
    },
  ];

  const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const last = entries.length - 1;
    if (event.key === "ArrowDown") {
      event.preventDefault();
      setActiveIndex((i) => (i >= last ? 0 : i + 1));
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      setActiveIndex((i) => (i <= 0 ? last : i - 1));
    } else if (event.key === "Home") {
      event.preventDefault();
      setActiveIndex(0);
    } else if (event.key === "End") {
      event.preventDefault();
      setActiveIndex(last);
    } else if (event.key === "Escape") {
      event.preventDefault();
      setMenuOpen(false);
      menuBtnRef.current?.focus();
    } else if (event.key === "Tab") {
      setMenuOpen(false);
    }
  };

  const onMenuButtonKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      setActiveIndex(0);
      setMenuOpen(true);
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      setActiveIndex(entries.length - 1);
      setMenuOpen(true);
    }
  };

  const subtitle = call
    ? `${call === "video" ? "Video" : "Voice"} call in progress`
    : muted
      ? "Notifications muted"
      : `${MEMBERS.length} members · ${online} active now`;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes chdr-halo { 0% { transform: scale(1); opacity: .5 } 70%, 100% { transform: scale(2.4); opacity: 0 } }
        @keyframes chdr-blink { 0%, 100% { opacity: 1 } 50% { opacity: .3 } }
        @media (prefers-reduced-motion: reduce) {
          .chdr-halo, .chdr-blink { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-3xl">
        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
          {/* ── header ─────────────────────────────────────── */}
          <header className="relative border-b border-slate-200 bg-white/90 backdrop-blur dark:border-slate-800 dark:bg-slate-900/90">
            <div className="flex items-center gap-3 px-3 py-3 sm:px-4">
              {/* avatar stack */}
              <div className="relative flex shrink-0 items-center">
                <div className="flex -space-x-2">
                  {MEMBERS.slice(0, 3).map((m) => (
                    <span
                      key={m.id}
                      className={`inline-flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br ${m.ring} text-[11px] font-semibold tracking-wide text-white ring-2 ring-white dark:ring-slate-900`}
                    >
                      {m.initials}
                    </span>
                  ))}
                </div>
                <span className="relative -ml-1 flex h-3 w-3 self-end">
                  {!reduce && (
                    <span className="chdr-halo absolute inset-0 rounded-full bg-emerald-500 [animation:chdr-halo_2.4s_cubic-bezier(0,0,.2,1)_infinite]" />
                  )}
                  <span className="relative h-3 w-3 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-slate-900" />
                </span>
              </div>

              {/* title block */}
              <div className="min-w-0 flex-1">
                <div className="flex items-center gap-1.5">
                  <h2 className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
                    Checkout rebuild
                  </h2>
                  {pinned && (
                    <span
                      className="inline-flex shrink-0 text-indigo-500 dark:text-indigo-400"
                      title="Pinned conversation"
                    >
                      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="h-3.5 w-3.5">
                        <path d="M14.4 3.6 20.4 9.6l-2.3.5a3 3 0 0 0-1.6.9l-2.3 2.4.7 3.2-6-6 3.2.7 2.4-2.3a3 3 0 0 0 .9-1.6zM8.9 15.1 4.5 19.5" />
                      </svg>
                      <span className="sr-only">Pinned</span>
                    </span>
                  )}
                  {unread && (
                    <span className="inline-flex shrink-0 items-center rounded-full bg-rose-100 px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300">
                      Unread
                    </span>
                  )}
                </div>
                <p className="mt-0.5 flex items-center gap-1.5 truncate text-xs text-slate-500 dark:text-slate-400">
                  {call && (
                    <span
                      className={`chdr-blink h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500 ${
                        reduce ? "" : "[animation:chdr-blink_1.4s_ease-in-out_infinite]"
                      }`}
                    />
                  )}
                  {muted && !call && <span className="shrink-0 text-slate-400 dark:text-slate-500">{"⊘"}</span>}
                  <span className="truncate">{subtitle}</span>
                </p>
              </div>

              {/* actions */}
              <div className="flex shrink-0 items-center gap-0.5">
                <button
                  ref={searchBtnRef}
                  type="button"
                  className={searchOpen ? iconBtnOn : iconBtn}
                  aria-expanded={searchOpen}
                  aria-controls={searchId}
                  onClick={() => (searchOpen ? closeSearch() : openSearch())}
                >
                  <IconSearch />
                  <span className="sr-only">Search this conversation</span>
                </button>

                <button
                  type="button"
                  className={call === "voice" ? iconBtnOn : iconBtn}
                  onClick={() => (call === "voice" ? endCall() : startCall("voice"))}
                >
                  <IconPhone />
                  <span className="sr-only">{call === "voice" ? "End voice call" : "Start voice call"}</span>
                </button>

                <button
                  type="button"
                  className={`hidden sm:inline-flex ${call === "video" ? iconBtnOn : iconBtn}`}
                  onClick={() => (call === "video" ? endCall() : startCall("video"))}
                >
                  <IconVideo />
                  <span className="sr-only">{call === "video" ? "End video call" : "Start video call"}</span>
                </button>

                <button
                  ref={detailsBtnRef}
                  type="button"
                  className={`hidden sm:inline-flex ${detailsOpen ? iconBtnOn : iconBtn}`}
                  aria-expanded={detailsOpen}
                  aria-controls={detailsId}
                  onClick={toggleDetails}
                >
                  <IconInfo />
                  <span className="sr-only">Chat details</span>
                </button>

                <div className="relative" ref={menuWrapRef}>
                  <button
                    ref={menuBtnRef}
                    type="button"
                    className={menuOpen ? iconBtnOn : iconBtn}
                    aria-haspopup="menu"
                    aria-expanded={menuOpen}
                    aria-controls={menuId}
                    onKeyDown={onMenuButtonKeyDown}
                    onClick={() => {
                      setActiveIndex(0);
                      setMenuOpen((v) => !v);
                    }}
                  >
                    <IconDots />
                    <span className="sr-only">More actions</span>
                  </button>

                  <AnimatePresence>
                    {menuOpen && (
                      <motion.div
                        id={menuId}
                        role="menu"
                        aria-label="Conversation actions"
                        onKeyDown={onMenuKeyDown}
                        initial={reduce ? false : { opacity: 0, y: -6, scale: 0.97 }}
                        animate={{ opacity: 1, y: 0, scale: 1 }}
                        exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
                        transition={{ duration: 0.14, ease: [0.16, 1, 0.3, 1] }}
                        className="absolute right-0 top-11 z-30 w-60 origin-top-right overflow-hidden rounded-xl border border-slate-200 bg-white p-1 shadow-lg shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-800 dark:shadow-black/50"
                      >
                        {entries.map((entry, i) => (
                          <button
                            key={entry.id}
                            ref={(node) => {
                              itemRefs.current[i] = node;
                            }}
                            type="button"
                            role={entry.checkbox ? "menuitemcheckbox" : "menuitem"}
                            aria-checked={entry.checkbox ? entry.checked : undefined}
                            tabIndex={i === activeIndex ? 0 : -1}
                            onFocus={() => setActiveIndex(i)}
                            onClick={entry.run}
                            className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-700 transition-colors hover:bg-slate-100 focus-visible:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:text-slate-200 dark:hover:bg-slate-700/70 dark:focus-visible:bg-slate-700/70"
                          >
                            <span className="flex h-4 w-4 shrink-0 items-center justify-center text-indigo-600 dark:text-indigo-400">
                              {entry.checkbox && entry.checked ? <IconCheck /> : null}
                              {entry.id === "video" && !entry.checkbox ? <IconVideo /> : null}
                              {entry.id === "details" && !entry.checkbox ? <IconInfo /> : null}
                            </span>
                            <span className="flex-1">{entry.label}</span>
                            {entry.id === "mute" && (
                              <span className="text-slate-400 dark:text-slate-500">
                                <IconBellOff />
                              </span>
                            )}
                            {entry.id === "pin" && (
                              <span className="text-slate-400 dark:text-slate-500">
                                <IconPin />
                              </span>
                            )}
                          </button>
                        ))}
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
              </div>
            </div>

            {/* search row */}
            <AnimatePresence initial={false}>
              {searchOpen && (
                <motion.div
                  id={searchId}
                  key="search"
                  initial={reduce ? false : { height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                  transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
                  className="overflow-hidden border-t border-slate-200 dark:border-slate-800"
                >
                  <div className="flex items-center gap-2 px-3 py-2.5 sm:px-4">
                    <span className="text-slate-400 dark:text-slate-500">
                      <IconSearch />
                    </span>
                    <input
                      ref={searchInputRef}
                      type="text"
                      value={query}
                      onChange={(event) => setQuery(event.target.value)}
                      onKeyDown={(event) => {
                        if (event.key === "Escape") {
                          event.preventDefault();
                          closeSearch();
                        }
                      }}
                      placeholder="Search messages, e.g. refund"
                      aria-label="Search messages in Checkout rebuild"
                      className="w-full bg-transparent text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
                    />
                    <span
                      aria-live="polite"
                      className="shrink-0 text-xs tabular-nums text-slate-500 dark:text-slate-400"
                    >
                      {query.trim() ? `${results.length} of ${MESSAGES.length}` : ""}
                    </span>
                    <button type="button" onClick={closeSearch} className={iconBtn}>
                      <IconClose />
                      <span className="sr-only">Close search</span>
                    </button>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>

            {/* call ribbon */}
            <AnimatePresence initial={false}>
              {call && (
                <motion.div
                  key="call"
                  initial={reduce ? false : { height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
                  transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
                  className="overflow-hidden border-t border-emerald-200 bg-emerald-50 dark:border-emerald-500/25 dark:bg-emerald-500/10"
                >
                  <div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2.5 sm:px-4">
                    <span className="text-emerald-700 dark:text-emerald-300">
                      {call === "video" ? <IconVideo /> : <IconPhone />}
                    </span>
                    <p className="text-xs font-medium text-emerald-900 dark:text-emerald-200">
                      {call === "video" ? "Video call" : "Voice call"} · 3 joined
                    </p>
                    <span className="rounded-md bg-emerald-600/10 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-emerald-800 dark:bg-emerald-400/15 dark:text-emerald-200">
                      {formatClock(elapsed)}
                    </span>
                    <div className="ml-auto flex items-center gap-2">
                      <button
                        type="button"
                        aria-pressed={micOff}
                        onClick={() => {
                          setMicOff(!micOff);
                          setAnnounce(micOff ? "Microphone on" : "Microphone muted");
                        }}
                        className="rounded-lg border border-emerald-300 bg-white px-2.5 py-1 text-xs font-medium text-emerald-800 transition-colors hover:bg-emerald-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-50 dark:border-emerald-500/30 dark:bg-slate-900 dark:text-emerald-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                      >
                        {micOff ? "Unmute mic" : "Mute mic"}
                      </button>
                      <button
                        type="button"
                        onClick={endCall}
                        className="inline-flex items-center gap-1.5 rounded-lg bg-rose-600 px-2.5 py-1 text-xs font-semibold text-white transition-colors hover:bg-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2 focus-visible:ring-offset-emerald-50 dark:focus-visible:ring-offset-slate-900"
                      >
                        <IconHangUp />
                        End
                      </button>
                    </div>
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </header>

          {/* ── body ───────────────────────────────────────── */}
          <div className="relative">
            <div className="h-[22rem] overflow-y-auto bg-slate-50/60 px-3 py-4 sm:px-4 dark:bg-slate-950/40">
              {results.length === 0 ? (
                <p className="py-12 text-center text-sm text-slate-500 dark:text-slate-400">
                  No messages match “{query.trim()}”.
                </p>
              ) : (
                <ul className="space-y-3">
                  {results.map((message) => {
                    const author = byId(message.authorId);
                    return (
                      <li key={message.id} className="flex gap-2.5">
                        <span
                          className={`mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${author.ring} text-[10px] font-semibold text-white`}
                        >
                          {author.initials}
                        </span>
                        <div className="min-w-0">
                          <p className="flex items-baseline gap-2">
                            <span className="text-xs font-semibold text-slate-900 dark:text-slate-100">
                              {author.name}
                            </span>
                            <span className="text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
                              {message.time}
                            </span>
                          </p>
                          <p className="mt-0.5 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
                            {highlight(message.text, query)}
                          </p>
                        </div>
                      </li>
                    );
                  })}
                </ul>
              )}
            </div>

            {/* details panel */}
            <AnimatePresence>
              {detailsOpen && (
                <motion.aside
                  id={detailsId}
                  key="details"
                  aria-label="Chat details"
                  initial={reduce ? { opacity: 0 } : { x: "100%" }}
                  animate={reduce ? { opacity: 1 } : { x: 0 }}
                  exit={reduce ? { opacity: 0 } : { x: "100%" }}
                  transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
                  onKeyDown={(event) => {
                    if (event.key === "Escape") {
                      event.preventDefault();
                      setDetailsOpen(false);
                      detailsBtnRef.current?.focus();
                    }
                  }}
                  className="absolute inset-y-0 right-0 z-20 w-full max-w-[17rem] overflow-y-auto border-l border-slate-200 bg-white p-4 shadow-2xl shadow-slate-900/10 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/50"
                >
                  <div className="flex items-center justify-between">
                    <h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                      Details
                    </h3>
                    <button
                      type="button"
                      className={iconBtn}
                      onClick={() => {
                        setDetailsOpen(false);
                        detailsBtnRef.current?.focus();
                      }}
                    >
                      <IconClose />
                      <span className="sr-only">Close chat details</span>
                    </button>
                  </div>

                  <dl className="mt-4 space-y-2 text-[13px]">
                    <div className="flex justify-between gap-2">
                      <dt className="text-slate-500 dark:text-slate-400">Created</dt>
                      <dd className="text-slate-800 dark:text-slate-200">14 Feb 2026</dd>
                    </div>
                    <div className="flex justify-between gap-2">
                      <dt className="text-slate-500 dark:text-slate-400">Messages</dt>
                      <dd className="tabular-nums text-slate-800 dark:text-slate-200">1,284</dd>
                    </div>
                    <div className="flex justify-between gap-2">
                      <dt className="text-slate-500 dark:text-slate-400">Shared files</dt>
                      <dd className="tabular-nums text-slate-800 dark:text-slate-200">37</dd>
                    </div>
                  </dl>

                  <h4 className="mt-5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                    Members
                  </h4>
                  <ul className="mt-2 space-y-2.5">
                    {MEMBERS.map((m) => (
                      <li key={m.id} className="flex items-center gap-2.5">
                        <span className="relative shrink-0">
                          <span
                            className={`inline-flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br ${m.ring} text-[10px] font-semibold text-white`}
                          >
                            {m.initials}
                          </span>
                          <span
                            className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-white dark:ring-slate-900 ${PRESENCE_DOT[m.presence]}`}
                          />
                        </span>
                        <span className="min-w-0">
                          <span className="block truncate text-[13px] font-medium text-slate-800 dark:text-slate-200">
                            {m.name}
                          </span>
                          <span className="block truncate text-[11px] text-slate-500 dark:text-slate-400">
                            {m.role} · {PRESENCE_LABEL[m.presence]}
                          </span>
                        </span>
                      </li>
                    ))}
                  </ul>
                </motion.aside>
              )}
            </AnimatePresence>
          </div>

          {/* composer stub keeps the header in context */}
          <div className="border-t border-slate-200 px-3 py-2.5 sm:px-4 dark:border-slate-800">
            <p className="text-xs text-slate-400 dark:text-slate-500">
              Composer sits here — the header above owns search, calls, details and the overflow menu.
            </p>
          </div>
        </div>
      </div>

      <p aria-live="polite" className="sr-only">
        {announce}
      </p>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →