Chat List
Original · freeconversations list with unread counts
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"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
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 quoteSimilar components
Browse all →
Chat Bubbles
Originalmessage bubble styles both sides

Chat Conversation
Originalfull conversation thread with avatars

Chat Input
Originalchat composer with attach and send

Chat Support Widget
Originalfloating support chat widget

Chat Typing
Originaltyping indicator and delivery states

Chat Group
Originalgroup chat with member names

Chat AI
OriginalAI assistant chat interface

Chat Header
Originalchat window header with actions

Chat Attachments
Originalmessages with image and file attachments

Chat Reactions
Originalmessage reactions and replies

Chat Read Receipts
Originalread receipts and timestamps

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

