Unibox
ChatListItem
One conversation in the Unibox list: a motion row that staggers in, glides when a new reply reorders the inbox, and pops its unread badge.
Guidelines#
Use it for conversation lists (email, LinkedIn, WhatsApp) where recency and "your turn" matter.
What you provide
- Required:
chat(ChatWithProspect: thechatsrow plusprospect,accountand optionalcampaign),index(entrance order),isActive,selectionMode,isChecked,unreadCountandonSelect. - Optional:
onViewProfile(prospectId), which adds a button to the avatar's hover card;layoutDependency(pass the list); andref.
Anatomy
- The row is a button with
rounded-xl px-2.5 py-2.5andhover:bg-accent/70. Active addsbg-primary/[0.07] ring-1 ring-primary/15. - A 36px
ProspectAvatarwith an unread halo; its hover card opens after 350ms. - The name is
font-medium(font-semiboldwhen unread), followed byChannelBadge, a campaign megaphone and a shared or private icon. - Time is
text-[11px] tabular-nums: "h:mm a" today, "Yesterday", then "MM/dd/yyyy". Unread times are orange only in dark mode, because orange fails AA at 11px on light. reply_state: "awaiting_us"adds anemerald-600reply icon: they replied, so it's your turn.- The unread count is a
bg-primary text-[10px] font-semiboldpill.
Motion
- Entrance starts from
y: 14, scale 0.98 and 4px blur, onSPRING_ENTERplusstaggerDelay(index). Reordering usesSPRING_GLIDE. - The badge pops on
SPRING_POP, and a 1.3s primary wash crosses the row when a new message lands. - Reduced motion removes all of it.
Don't pass a new layoutDependency on every render, and don't nest buttons in the row.
Facts#
| Group | Unibox |
|---|---|
| Product | Max |
| Global | window.DigitalCrew.ChatListItem |
| Source | max-agent: src/features/unibox/ui/components/chat-list-item.tsx |
| Import in the app | import { ChatListItem } from "@/features/unibox/ui/components/chat-list-item"; |
API#
TypeScript declarations emitted from src/features/unibox/ui/components/chat-list-item.tsx.
interface ChatListItemProps {
chat: ChatWithProspect;
index: number;
isActive: boolean;
selectionMode: boolean;
isChecked: boolean;
unreadCount: number;
onSelect: () => void;
onViewProfile?: (prospectId: string) => void;
/**
* Layout re-measurement gate: pass the list identity so motion only
* re-measures rows when the list actually changes, not on every parent
* re-render (e.g. each search-box keystroke).
*/
layoutDependency?: unknown;
/** AnimatePresence popLayout measures the row through this ref. */
ref?: React.Ref<HTMLButtonElement>;
}
export declare function ChatListItem({ chat, index, isActive, selectionMode, isChecked, unreadCount, onSelect, onViewProfile, layoutDependency, ref, }: ChatListItemProps): JSX.Element;Example#
The code of the preview above: plain React.createElement against window.DigitalCrew, with realistic data.
<div id="root" class="p-6"></div>
<script>
(function () {
var h = React.createElement, DC = window.DigitalCrew, I = DC.Icons;
var MIN = 60 * 1000, DAY = 24 * 60 * MIN;
function ago(ms) { return new Date(Date.now() - ms).toISOString(); }
function chat(o) {
return {
id: o.id, account_id: "5a1c2e10-0000-4000-8000-000000000001", prospect_id: o.id + "-p",
workflow_execution_state_id: null, channel: o.channel, chat_id: "prov-" + o.id, title: null,
is_group: false, is_archived: false, unread_count: o.unread || 0,
last_message_at: o.at, last_message_preview: o.preview, metadata: {},
share_override: o.share || "inherit", reply_state: o.reply || null,
workspace_id: "9d7e0000-0000-4000-8000-000000000001", created_at: ago(30 * DAY), updated_at: o.at,
prospect: { id: o.id + "-p", full_name: o.name, first_name: null, last_name: null, email: null, linkedin_url: null, photo_url: null },
account: { id: "5a1c2e10-0000-4000-8000-000000000001", name: "Mathieu Laurent", email: "mathieu@digitalcrew.ai" },
campaign: o.campaign ? { id: "c0ffee00-0000-4000-8000-000000000001", name: o.campaign } : null
};
}
var CHATS = [
chat({ id: "c1", name: "Claire Dubois", channel: "linkedin", unread: 2, reply: "awaiting_us", campaign: "Q4 CFO outreach", at: ago(6 * MIN), preview: "Thursday at 10 works. Can you send the invite to my assistant?" }),
chat({ id: "c2", name: "Léa Martin", channel: "whatsapp", share: "shared", reply: "awaiting_them", at: ago(48 * MIN), preview: "You: Perfect, I'll share the deck tonight." }),
chat({ id: "c3", name: "Marcus Reinholt", channel: "email", unread: 1, reply: "awaiting_us", at: ago(2 * 60 * MIN), preview: "Re: Pipeline review — attached the numbers we discussed" }),
chat({ id: "c4", name: "Tomás Alvarez", channel: "email", reply: "awaiting_them", at: ago(DAY + 3 * 60 * MIN), preview: "You: Following up on the Brightline pilot proposal" }),
chat({ id: "c5", name: "Priya Raman", channel: "linkedin", share: "private", reply: "awaiting_them", at: ago(6 * DAY), preview: "You: Great meeting you at SaaStr — worth a quick chat?" })
];
function App() {
var st = React.useState("c2"), active = st[0], setActive = st[1];
var un = React.useState({ c1: 2, c3: 1 }), unread = un[0], setUnread = un[1];
return h("div", { className: "mx-auto flex w-[360px] flex-col gap-0.5 rounded-2xl border border-border/60 bg-card p-2 shadow-sm" },
CHATS.map(function (c, i) {
return h(DC.ChatListItem, {
key: c.id, chat: c, index: i, isActive: active === c.id, selectionMode: false, isChecked: false,
unreadCount: unread[c.id] || 0, layoutDependency: CHATS,
onSelect: function () {
setActive(c.id);
setUnread(function (u) { var n = Object.assign({}, u); delete n[c.id]; return n; });
}
});
}));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>