Design
llms.txt digitalcrew.tech

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.

MaxUniboxDigitalCrew.ChatListItem
ChatListItem · liveOpen

Guidelines#

Use it for conversation lists (email, LinkedIn, WhatsApp) where recency and "your turn" matter.

What you provide

  • Required: chat (ChatWithProspect: the chats row plus prospect, account and optional campaign), index (entrance order), isActive, selectionMode, isChecked, unreadCount and onSelect.
  • Optional: onViewProfile(prospectId), which adds a button to the avatar's hover card; layoutDependency (pass the list); and ref.

Anatomy

  • The row is a button with rounded-xl px-2.5 py-2.5 and hover:bg-accent/70. Active adds bg-primary/[0.07] ring-1 ring-primary/15.
  • A 36px ProspectAvatar with an unread halo; its hover card opens after 350ms.
  • The name is font-medium (font-semibold when unread), followed by ChannelBadge, 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 an emerald-600 reply icon: they replied, so it's your turn.
  • The unread count is a bg-primary text-[10px] font-semibold pill.

Motion

  • Entrance starts from y: 14, scale 0.98 and 4px blur, on SPRING_ENTER plus staggerDelay(index). Reordering uses SPRING_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#

GroupUnibox
ProductMax
Globalwindow.DigitalCrew.ChatListItem
Sourcemax-agent: src/features/unibox/ui/components/chat-list-item.tsx
Import in the appimport { 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>