Web InnoventixFreeCode

Chat List

Original · free

conversations list with unread counts

byWeb InnoventixReact + Tailwind
chatlist
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-list.json
chat-list.tsx
"use client";

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

type Presence = "online" | "away" | "offline";

type Conversation = {
  id: string;
  name: string;
  initials: string;
  handle: string;
  preview: string;
  sender: "them" | "you";
  timestamp: string;
  unread: number;
  presence: Presence;
  pinned: boolean;
  muted: boolean;
  tint: string;
};

const CONVERSATIONS: Conversation[] = [
  {
    id: "c-nadia",
    name: "Nadia Okonkwo",
    initials: "NO",
    handle: "@nadia",
    preview:
      "The staging build is green. I pushed the migration rollback script just in case.",
    sender: "them",
    timestamp: "2:41 PM",
    unread: 3,
    presence: "online",
    pinned: true,
    muted: false,
    tint: "from-indigo-500 to-violet-500",
  },
  {
    id: "c-platform",
    name: "Platform Standup",
    initials: "PS",
    handle: "7 members",
    preview:
      "Ravi: Postgres connection pool is at 82%. Bumping max_connections to 400 tonight.",
    sender: "them",
    timestamp: "1:58 PM",
    unread: 12,
    presence: "online",
    pinned: true,
    muted: false,
    tint: "from-emerald-500 to-sky-500",
  },
  {
    id: "c-jonas",
    name: "Jonas Weiler",
    initials: "JW",
    handle: "@jweiler",
    preview: "You: Sent the revised SOW — invoice terms are net 15 now.",
    sender: "you",
    timestamp: "11:20 AM",
    unread: 0,
    presence: "away",
    pinned: false,
    muted: false,
    tint: "from-amber-500 to-rose-500",
  },
  {
    id: "c-design",
    name: "Design Critique",
    initials: "DC",
    handle: "12 members",
    preview:
      "Priya: Dropped three variants of the empty state. Vote in the thread by Friday.",
    sender: "them",
    timestamp: "Yesterday",
    unread: 5,
    presence: "offline",
    pinned: false,
    muted: true,
    tint: "from-rose-500 to-violet-500",
  },
  {
    id: "c-marta",
    name: "Marta Ilves",
    initials: "MI",
    handle: "@marta.i",
    preview: "Can you review the pricing table copy before the 4pm sync?",
    sender: "them",
    timestamp: "Yesterday",
    unread: 1,
    presence: "online",
    pinned: false,
    muted: false,
    tint: "from-sky-500 to-indigo-500",
  },
  {
    id: "c-support",
    name: "Support Escalations",
    initials: "SE",
    handle: "24 members",
    preview:
      "Ticket #4821 reopened — customer reports the export CSV drops the last row.",
    sender: "them",
    timestamp: "Tue",
    unread: 0,
    presence: "online",
    pinned: false,
    muted: true,
    tint: "from-amber-500 to-emerald-500",
  },
  {
    id: "c-theo",
    name: "Theo Marchand",
    initials: "TM",
    handle: "@theo",
    preview: "You: Booked the room for Thursday. Bring the latency numbers.",
    sender: "you",
    timestamp: "Mon",
    unread: 0,
    presence: "offline",
    pinned: false,
    muted: false,
    tint: "from-zinc-500 to-slate-600",
  },
];

type FilterKey = "all" | "unread" | "groups";

const FILTERS: { key: FilterKey; label: string }[] = [
  { key: "all", label: "All" },
  { key: "unread", label: "Unread" },
  { key: "groups", label: "Groups" },
];

const PRESENCE_STYLE: Record<Presence, { dot: string; label: string }> = {
  online: { dot: "bg-emerald-500", label: "Online" },
  away: { dot: "bg-amber-500", label: "Away" },
  offline: { dot: "bg-zinc-400 dark:bg-zinc-600", label: "Offline" },
};

function SearchIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <circle cx="11" cy="11" r="7" />
      <path d="m20 20-3.5-3.5" />
    </svg>
  );
}

function PinIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-3.5 w-3.5"
      aria-hidden="true"
    >
      <path d="M12 17v5" />
      <path d="M9 3h6l-1 6 3 3v2H7v-2l3-3-1-6Z" />
    </svg>
  );
}

function MuteIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-3.5 w-3.5"
      aria-hidden="true"
    >
      <path d="M18 8a6 6 0 0 0-9.3-5" />
      <path d="M6 8v5l-2 3h11" />
      <path d="M10.5 19a2 2 0 0 0 3.4.4" />
      <path d="m3 3 18 18" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-3.5 w-3.5"
      aria-hidden="true"
    >
      <path d="m4 12 5 5L20 6" />
    </svg>
  );
}

function ClearIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-3.5 w-3.5"
      aria-hidden="true"
    >
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
}

export default function ChatList() {
  const reduceMotion = useReducedMotion();
  const searchId = useId();
  const listRef = useRef<HTMLUListElement>(null);

  const [filter, setFilter] = useState<FilterKey>("all");
  const [query, setQuery] = useState("");
  const [activeId, setActiveId] = useState<string>("c-nadia");
  const [readIds, setReadIds] = useState<Set<string>>(() => new Set());
  const [announcement, setAnnouncement] = useState("");

  const unreadFor = useCallback(
    (c: Conversation) => (readIds.has(c.id) ? 0 : c.unread),
    [readIds]
  );

  const visible = useMemo(() => {
    const q = query.trim().toLowerCase();
    const matched = CONVERSATIONS.filter((c) => {
      if (filter === "unread" && unreadFor(c) === 0) return false;
      if (filter === "groups" && !c.handle.includes("members")) return false;
      if (!q) return true;
      return (
        c.name.toLowerCase().includes(q) ||
        c.handle.toLowerCase().includes(q) ||
        c.preview.toLowerCase().includes(q)
      );
    });
    return [...matched].sort((a, b) => Number(b.pinned) - Number(a.pinned));
  }, [filter, query, unreadFor]);

  const totalUnread = useMemo(
    () => CONVERSATIONS.reduce((sum, c) => sum + unreadFor(c), 0),
    [unreadFor]
  );

  const open = useCallback(
    (c: Conversation) => {
      setActiveId(c.id);
      setReadIds((prev) => {
        if (prev.has(c.id)) return prev;
        const next = new Set(prev);
        next.add(c.id);
        return next;
      });
      setAnnouncement(`Opened conversation with ${c.name}. Marked as read.`);
    },
    []
  );

  const markAllRead = useCallback(() => {
    setReadIds(new Set(CONVERSATIONS.map((c) => c.id)));
    setAnnouncement("All conversations marked as read.");
  }, []);

  const onKeyDown = useCallback(
    (event: KeyboardEvent<HTMLUListElement>) => {
      const keys = ["ArrowDown", "ArrowUp", "Home", "End"];
      if (!keys.includes(event.key)) return;
      const nodes = Array.from(
        listRef.current?.querySelectorAll<HTMLButtonElement>(
          "button[data-row='true']"
        ) ?? []
      );
      if (nodes.length === 0) return;
      const current = nodes.findIndex((n) => n === document.activeElement);
      event.preventDefault();
      let next = current;
      if (event.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % nodes.length;
      if (event.key === "ArrowUp")
        next = current <= 0 ? nodes.length - 1 : current - 1;
      if (event.key === "Home") next = 0;
      if (event.key === "End") next = nodes.length - 1;
      nodes[next]?.focus();
    },
    []
  );

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-zinc-950">
      <style>{`
        @keyframes cvxl-pulse-ring {
          0% { transform: scale(1); opacity: 0.55; }
          70% { transform: scale(2.2); opacity: 0; }
          100% { transform: scale(2.2); opacity: 0; }
        }
        @keyframes cvxl-sheen {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(220%); }
        }
        .cvxl-ring::after {
          content: "";
          position: absolute;
          inset: 0;
          border-radius: 9999px;
          background: inherit;
          animation: cvxl-pulse-ring 2.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
        }
        .cvxl-sheen::before {
          content: "";
          position: absolute;
          top: 0;
          bottom: 0;
          width: 40%;
          background: linear-gradient(90deg, transparent, rgba(255,255,255,0.28), transparent);
          animation: cvxl-sheen 2.4s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .cvxl-ring::after,
          .cvxl-sheen::before {
            animation: none;
            opacity: 0;
          }
        }
      `}</style>

      <div className="mx-auto w-full max-w-2xl">
        <header className="mb-6 flex flex-wrap items-end justify-between gap-4">
          <div>
            <h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-zinc-50">
              Messages
            </h2>
            <p className="mt-1.5 text-sm text-slate-600 dark:text-zinc-400">
              {totalUnread > 0
                ? `${totalUnread} unread across ${
                    CONVERSATIONS.filter((c) => unreadFor(c) > 0).length
                  } conversations`
                : "You're all caught up."}
            </p>
          </div>
          <button
            type="button"
            onClick={markAllRead}
            disabled={totalUnread === 0}
            className="inline-flex items-center gap-1.5 rounded-full border border-slate-300 bg-white px-3.5 py-2 text-xs font-medium text-slate-700 transition hover:border-slate-400 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-45 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800 dark:focus-visible:ring-offset-zinc-950"
          >
            <CheckIcon />
            Mark all read
          </button>
        </header>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
          <div className="border-b border-slate-200 p-3 sm:p-4 dark:border-zinc-800">
            <div className="relative">
              <label htmlFor={searchId} className="sr-only">
                Search conversations
              </label>
              <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-zinc-500">
                <SearchIcon />
              </span>
              <input
                id={searchId}
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search people, groups, messages"
                className="w-full rounded-xl border border-slate-200 bg-slate-50 py-2.5 pl-9 pr-9 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:border-indigo-400 dark:focus-visible:ring-indigo-400/30 [&::-webkit-search-cancel-button]:appearance-none"
              />
              {query.length > 0 && (
                <button
                  type="button"
                  onClick={() => setQuery("")}
                  aria-label="Clear search"
                  className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-slate-400 transition hover:bg-slate-200 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-zinc-500 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
                >
                  <ClearIcon />
                </button>
              )}
            </div>

            <div
              role="group"
              aria-label="Filter conversations"
              className="mt-3 flex gap-1 rounded-xl bg-slate-100 p-1 dark:bg-zinc-950"
            >
              {FILTERS.map((f) => {
                const selected = filter === f.key;
                const count =
                  f.key === "unread"
                    ? CONVERSATIONS.filter((c) => unreadFor(c) > 0).length
                    : null;
                return (
                  <button
                    key={f.key}
                    type="button"
                    aria-pressed={selected}
                    onClick={() => setFilter(f.key)}
                    className={`relative flex-1 rounded-lg px-3 py-1.5 text-xs font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-zinc-950 ${
                      selected
                        ? "text-slate-900 dark:text-zinc-50"
                        : "text-slate-500 hover:text-slate-800 dark:text-zinc-500 dark:hover:text-zinc-200"
                    }`}
                  >
                    {selected && (
                      <motion.span
                        layoutId="cvxl-filter-pill"
                        transition={
                          reduceMotion
                            ? { duration: 0 }
                            : { type: "spring", stiffness: 420, damping: 34 }
                        }
                        className="absolute inset-0 rounded-lg bg-white shadow-sm dark:bg-zinc-800"
                      />
                    )}
                    <span className="relative flex items-center justify-center gap-1.5">
                      {f.label}
                      {count !== null && count > 0 && (
                        <span className="rounded-full bg-indigo-500 px-1.5 text-[10px] font-semibold leading-4 text-white">
                          {count}
                        </span>
                      )}
                    </span>
                  </button>
                );
              })}
            </div>
          </div>

          <ul
            ref={listRef}
            onKeyDown={onKeyDown}
            aria-label="Conversations"
            className="max-h-[30rem] divide-y divide-slate-100 overflow-y-auto dark:divide-zinc-800/70"
          >
            <AnimatePresence initial={false} mode="popLayout">
              {visible.map((c) => {
                const unread = unreadFor(c);
                const isActive = activeId === c.id;
                const presence = PRESENCE_STYLE[c.presence];
                return (
                  <motion.li
                    key={c.id}
                    layout={!reduceMotion}
                    initial={reduceMotion ? false : { opacity: 0, y: -6 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
                    transition={
                      reduceMotion
                        ? { duration: 0 }
                        : { type: "spring", stiffness: 500, damping: 40 }
                    }
                  >
                    <button
                      type="button"
                      data-row="true"
                      onClick={() => open(c)}
                      aria-current={isActive ? "true" : undefined}
                      className={`group relative flex w-full items-start gap-3 px-3 py-3.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:px-4 ${
                        isActive
                          ? "bg-indigo-50/70 dark:bg-indigo-500/10"
                          : "hover:bg-slate-50 dark:hover:bg-zinc-800/50"
                      }`}
                    >
                      {isActive && (
                        <span
                          aria-hidden="true"
                          className="absolute inset-y-0 left-0 w-0.5 bg-indigo-500 dark:bg-indigo-400"
                        />
                      )}

                      <span className="relative shrink-0">
                        <span
                          aria-hidden="true"
                          className={`flex h-11 w-11 items-center justify-center rounded-full bg-gradient-to-br ${c.tint} text-sm font-semibold text-white`}
                        >
                          {c.initials}
                        </span>
                        <span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-white dark:bg-zinc-900">
                          <span
                            className={`relative h-2.5 w-2.5 rounded-full ${presence.dot} ${
                              c.presence === "online" && !reduceMotion
                                ? "cvxl-ring"
                                : ""
                            }`}
                          />
                        </span>
                        <span className="sr-only">{presence.label}.</span>
                      </span>

                      <span className="min-w-0 flex-1">
                        <span className="flex items-baseline justify-between gap-2">
                          <span className="flex min-w-0 items-center gap-1.5">
                            <span
                              className={`truncate text-sm ${
                                unread > 0
                                  ? "font-semibold text-slate-900 dark:text-zinc-50"
                                  : "font-medium text-slate-700 dark:text-zinc-300"
                              }`}
                            >
                              {c.name}
                            </span>
                            {c.pinned && (
                              <span
                                className="shrink-0 text-slate-400 dark:text-zinc-500"
                                title="Pinned"
                              >
                                <PinIcon />
                                <span className="sr-only">Pinned.</span>
                              </span>
                            )}
                            {c.muted && (
                              <span
                                className="shrink-0 text-slate-400 dark:text-zinc-500"
                                title="Muted"
                              >
                                <MuteIcon />
                                <span className="sr-only">Muted.</span>
                              </span>
                            )}
                          </span>
                          <span
                            className={`shrink-0 text-[11px] tabular-nums ${
                              unread > 0
                                ? "font-medium text-indigo-600 dark:text-indigo-400"
                                : "text-slate-400 dark:text-zinc-500"
                            }`}
                          >
                            {c.timestamp}
                          </span>
                        </span>

                        <span className="mt-1 flex items-center justify-between gap-3">
                          <span
                            className={`line-clamp-1 text-xs leading-relaxed ${
                              unread > 0
                                ? "text-slate-700 dark:text-zinc-300"
                                : "text-slate-500 dark:text-zinc-500"
                            }`}
                          >
                            {c.preview}
                          </span>

                          {unread > 0 ? (
                            <span
                              className={`relative flex h-5 min-w-5 shrink-0 items-center justify-center overflow-hidden rounded-full px-1.5 text-[11px] font-semibold tabular-nums text-white ${
                                c.muted
                                  ? "bg-slate-400 dark:bg-zinc-600"
                                  : "cvxl-sheen bg-indigo-600 dark:bg-indigo-500"
                              }`}
                            >
                              <span className="relative">
                                {unread > 9 ? "9+" : unread}
                              </span>
                              <span className="sr-only">
                                {unread} unread messages
                              </span>
                            </span>
                          ) : (
                            <span className="h-5 w-5 shrink-0" aria-hidden="true" />
                          )}
                        </span>
                      </span>
                    </button>
                  </motion.li>
                );
              })}
            </AnimatePresence>

            {visible.length === 0 && (
              <li className="px-6 py-14 text-center">
                <p className="text-sm font-medium text-slate-700 dark:text-zinc-300">
                  No conversations match
                </p>
                <p className="mt-1 text-xs text-slate-500 dark:text-zinc-500">
                  Try a different name, or switch back to the All filter.
                </p>
              </li>
            )}
          </ul>

          <div className="flex items-center justify-between border-t border-slate-200 px-4 py-2.5 text-[11px] text-slate-500 dark:border-zinc-800 dark:text-zinc-500">
            <span>
              Showing {visible.length} of {CONVERSATIONS.length}
            </span>
            <span className="hidden sm:inline">
              Use ↑ ↓ to browse, Enter to open
            </span>
          </div>
        </div>

        <p aria-live="polite" className="sr-only">
          {announcement}
        </p>
      </div>
    </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 →