Unibox
MessageBubble
One message in a Unibox thread: your side in the brand gradient, theirs in muted glass, each rising in from its own side of the conversation.
Guidelines#
Use it for email, LinkedIn and WhatsApp threads. Render bubbles in a flex flex-col container, because they align with self-end and self-start.
What you provide
message: aMessageEntity, meaning amessagesrow withdirection,status,subject,text,attachments,sent_at,is_privateandmetadata.enterIndex: position with the newest message first (default 0).layoutDependency: pass the messages array.- For the account owner:
canManagePrivacy(default false),onTogglePrivacy(message, isPrivate)andprivacyPending.
Anatomy
- Size:
max-w-[min(600px,88%)] px-3.5 py-2.5. - Outbound:
rounded-[18px_18px_4px_18px],from-primary to-[oklch(0.62_0.19_35)]gradient,text-primary-foreground,shadow-md shadow-primary/25. - Inbound:
rounded-[18px_18px_18px_4px],bg-muted/80 ring-1 ring-border/50 backdrop-blur-sm. - Private messages get an amber ring and an "Only you" lock.
- The subject is
text-sm font-semiboldover a hairline. Bodies over 700 characters fold to 500 behind "Show more". - Attachments: images inline, audio and video with players, other files as outline chips with their size.
- Footer:
text-xs font-light italictime and, on outbound messages, a delivery-status icon with a tooltip.
Motion: enters from y: 16, x: ±12 (the sender's side), scale 0.96 and 4px blur, on SPRING_ENTER with staggerDelay(enterIndex, 0.03, 0.24). Reduced motion renders it in place.
Don't add names or avatars inside the bubble. Its side already says who spoke.
Facts#
| Group | Unibox |
|---|---|
| Product | Max |
| Global | window.DigitalCrew.MessageBubble |
| Source | max-agent: src/features/unibox/ui/components/message-bubble.tsx |
| Import in the app | import { MessageBubble } from "@/features/unibox/ui/components/message-bubble"; |
API#
TypeScript declarations emitted from src/features/unibox/ui/components/message-bubble.tsx.
interface MessageBubbleProps {
message: MessageEntity;
/**
* Position in the thread's render order (newest first), used to stagger the
* entrance when a conversation opens. New arrivals get index 0.
*/
enterIndex?: number;
/**
* Layout re-measurement gate: pass the messages identity so motion only
* re-measures bubbles when the thread changes, not on every parent
* re-render (e.g. each composer keystroke).
*/
layoutDependency?: unknown;
/**
* The viewer connected the account behind this thread, so they may hide
* individual messages from everyone else. Undefined for everyone else —
* the control simply isn't rendered.
*/
canManagePrivacy?: boolean;
/** Toggle this message's privacy; the parent persists and refreshes. */
onTogglePrivacy?: (message: MessageEntity, isPrivate: boolean) => void;
/** True while the toggle is in flight, so the control can't double-fire. */
privacyPending?: boolean;
}
export declare function MessageBubble({ message: msg, enterIndex, layoutDependency, canManagePrivacy, onTogglePrivacy, privacyPending, }: MessageBubbleProps): 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;
function ago(m) { return new Date(Date.now() - m * MIN).toISOString(); }
function msg(o) {
var t = ago(o.ago);
return {
id: o.id, chat_id: "7f3a0000-0000-4000-8000-000000000001", channel: "email", message_id: "prov-" + o.id,
parent_message_id: null, replied_message_id: null, in_reply_to: null,
subject: o.subject || null, text: o.text, html_content: null,
direction: o.dir, status: o.status || null, open_count: o.status === "read" ? 3 : 0, click_count: 0,
tracking_id: null, mail_type: "single", attachments: o.attachments || [],
prospect_id: "p-claire", account_id: "5a1c2e10-0000-4000-8000-000000000001", campaign_id: null,
workflow_node_id: null, workflow_execution_id: null, workflow_execution_state_id: null,
sent_at: t, delivered_at: o.dir === "outbound" ? t : null, open_at: o.status === "read" ? ago(o.ago - 4) : null,
clicked_at: null, replied_at: null, bounced_at: null, unsubscribed_at: null, marked_as_spam_at: null,
error_category: null, error_reason: null, workspace_id: "9d7e0000-0000-4000-8000-000000000001",
is_private: false, metadata: {}, created_at: t, status_changed_at: t, updated_at: t
};
}
var THREAD = [
msg({ id: "m1", dir: "outbound", ago: 190, status: "read", subject: "Northwind × Digital Crew: faster month-end close",
text: "Hi Claire, congrats on the new role. Finance teams your size lose two days a month to manual reconciliation. Worth 20 minutes next week?" }),
msg({ id: "m2", dir: "inbound", ago: 150,
text: "Hi Mathieu, timing is good: we're reviewing tooling for Q1. Before a call, our security team needs this questionnaire back.",
attachments: [{ id: "att-1", kind: "file", name: "Northwind-security-questionnaire.pdf", mime: "application/pdf", size_bytes: 253952, duration: null, voice_note: null, unavailable: null, storage_path: null }] }),
msg({ id: "m3", dir: "outbound", ago: 64, status: "delivered", text: "Done, returned it with our SOC 2 report. Does Thursday at 10:00 CET work for a first call?" }),
msg({ id: "m4", dir: "inbound", ago: 12, text: "Thursday at 10 works. Please send the invite to my assistant, Anouk." }),
msg({ id: "m5", dir: "outbound", ago: 3, status: "sent", text: "Invite sent to you and Anouk. Talk Thursday!" })
];
function App() {
return h("div", { className: "mx-auto flex max-w-[640px] flex-col gap-2 rounded-2xl border border-border/60 bg-background p-4" },
THREAD.map(function (m, i) {
return h(DC.MessageBubble, { key: m.id, message: m, enterIndex: THREAD.length - 1 - i, layoutDependency: THREAD });
}));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>