Motion
UniboxMotion
The Unibox's shared motion vocabulary: every surface moves on the same few springs, so the whole inbox feels like one physical material.
Guidelines#
It is exported as DigitalCrew.uniboxMotion.
Use it for any new Unibox element, and for surfaces that should feel like the inbox. Pass the tokens as motion/react transitions.
The tokens
SPRING_GLIDE:{ type: "spring", stiffness: 380, damping: 34, mass: 0.9 }, for rows gliding to a new place when a list reorders. Used by theChatListItemlayout and theChannelSegmentspill.SPRING_ENTER: stiffness 320, damping 30, mass 0.8, for elements materializing on screen. Used by rows, bubbles, the hover card and reply cards.SPRING_POP: stiffness 520, damping 28, mass 0.6, for small accents that pop, such as unread badges, the send button and pills.FADE_SWAP:{ duration: 0.16, ease: "easeOut" }, for crossfades and exits where a spring would feel heavy.staggerDelay(index, step = 0.035, cap = 0.32):min(index × step, cap)seconds, so long lists never feel slow.MessageBubbleuses(0.03, 0.24).
Entrances: rows start at opacity 0, y: 14, scale 0.98 and blur(4px). Bubbles start at y: 16, x: ±12 from the sender's side. Exits take 0.12–0.14s.
Reduced motion: the Unibox's motion components (rows, bubbles, badges, segments, hover card, suggestion cards) read useReducedMotion(). When it is on, they skip entrances, glides and ripples.
Don't invent per-component springs or durations. Pick the token whose purpose matches.
Facts#
| Group | Motion |
|---|---|
| Product | Max |
| Kind | Showcase page (tokens and patterns, not a single export) |
| Source | max-agent: src/features/unibox/ui/lib/motion.ts |
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, M = DC.uniboxMotion;
var MIN = 60 * 1000, DAY = 24 * 60 * MIN;
// ── Spring response, simulated from the exported stiffness / damping / mass ──
function simulate(cfg, ms) {
var x = 0, v = 0, dt = 0.001, pts = [], peak = 1, settle = 0;
for (var i = 0; i <= ms; i++) {
pts.push(x);
if (x > peak) peak = x;
if (Math.abs(x - 1) > 0.01) settle = i;
var a = (-cfg.stiffness * (x - 1) - cfg.damping * v) / cfg.mass;
v += a * dt; x += v * dt;
}
return { pts: pts, peak: peak, settle: settle };
}
function Plot(props) {
var W = 220, H = 56, ms = 600, sim = props.sim, top = Math.max(1.06, sim.peak + 0.02);
var d = sim.pts.filter(function (_, i) { return i % 6 === 0; }).map(function (x, j) {
return (j === 0 ? "M" : "L") + (j * 6 / ms * W).toFixed(1) + " " + (H - x / top * H).toFixed(1);
}).join(" ");
var one = H - 1 / top * H;
return h("svg", { viewBox: "0 0 " + W + " " + H, className: "h-14 w-full overflow-visible", preserveAspectRatio: "none" },
h("line", { x1: 0, x2: W, y1: one, y2: one, stroke: "currentColor", strokeOpacity: 0.18, strokeDasharray: "3 3" }),
h("line", { x1: sim.settle / ms * W, x2: sim.settle / ms * W, y1: 0, y2: H, stroke: "currentColor", strokeOpacity: 0.18 }),
h("path", { d: d, fill: "none", stroke: "var(--primary)", strokeWidth: 2, vectorEffect: "non-scaling-stroke" }));
}
var SPRINGS = [
{ key: "SPRING_GLIDE", use: "Rows gliding to a new position when the list reorders.", where: "ChatListItem reorder, ChannelSegments pill" },
{ key: "SPRING_ENTER", use: "Elements materializing on screen (messages, cards, rows).", where: "ChatListItem, MessageBubble, hover card, reply cards" },
{ key: "SPRING_POP", use: "Small accents that pop: unread badges, send button, pills.", where: "Unread count badge" }
];
function SpringCard(s) {
var cfg = M[s.key], sim = simulate(cfg, 600);
var zeta = cfg.damping / (2 * Math.sqrt(cfg.stiffness * cfg.mass));
return h("div", { key: s.key, className: "flex flex-col gap-2 rounded-xl border border-border/60 bg-card p-3.5" },
h("div", { className: "flex items-baseline justify-between gap-2" },
h("code", { className: "font-mono text-xs font-semibold" }, s.key),
h("span", { className: "font-mono text-[10.5px] tabular-nums text-muted-foreground" },
"k " + cfg.stiffness + " · c " + cfg.damping + " · m " + cfg.mass)),
h("p", { className: "text-xs leading-snug text-muted-foreground" }, s.use),
h("div", { className: "text-muted-foreground" }, h(Plot, { sim: sim })),
h("div", { className: "flex justify-between text-[10.5px] tabular-nums text-muted-foreground" },
h("span", null, "ζ " + zeta.toFixed(2) + (sim.peak > 1.001 ? " · overshoot " + ((sim.peak - 1) * 100).toFixed(1) + "%" : " · no overshoot")),
h("span", null, "within 1% at ~" + sim.settle + "ms")),
h("div", { className: "text-[10.5px] text-muted-foreground/80" }, s.where));
}
function FadeCard() {
var f = M.FADE_SWAP;
return h("div", { className: "flex flex-col gap-2 rounded-xl border border-border/60 bg-card p-3.5" },
h("div", { className: "flex items-baseline justify-between gap-2" },
h("code", { className: "font-mono text-xs font-semibold" }, "FADE_SWAP"),
h("span", { className: "font-mono text-[10.5px] tabular-nums text-muted-foreground" }, f.duration + "s · " + f.ease)),
h("p", { className: "text-xs leading-snug text-muted-foreground" }, "Quick crossfades where a spring would feel heavy (exits, swaps)."),
h("div", { className: "relative mt-3 h-2 w-full rounded-full bg-muted" },
h("div", { className: "absolute inset-y-0 left-0 rounded-full bg-primary", style: { width: (f.duration * 1000 / 600 * 100) + "%" } })),
h("div", { className: "flex justify-between text-[10.5px] tabular-nums text-muted-foreground" },
h("span", null, (f.duration * 1000) + "ms of a 600ms axis"), h("span", null, "no spring, no overshoot")),
h("div", { className: "mt-auto text-[10.5px] text-muted-foreground/80" }, "Reply suggestions: skeleton, error and list swaps"));
}
function StaggerCard() {
var rows = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
return h("div", { className: "col-span-2 flex flex-col gap-2 rounded-xl border border-border/60 bg-card p-3.5" },
h("div", { className: "flex items-baseline justify-between gap-2" },
h("code", { className: "font-mono text-xs font-semibold" }, "staggerDelay(index, step = 0.035, cap = 0.32)"),
h("span", { className: "text-[10.5px] text-muted-foreground" }, "min(index × step, cap)")),
h("div", { className: "flex items-end gap-1.5" },
rows.map(function (i) {
var s = M.staggerDelay(i);
return h("div", { key: i, className: "flex flex-1 flex-col items-center gap-1" },
h("div", { className: "w-2.5 rounded-sm bg-primary/70", style: { height: Math.max(2, s / 0.32 * 28) + "px" } }),
h("span", { className: "font-mono text-[9.5px] tabular-nums text-muted-foreground" }, Math.round(s * 1000)));
})),
h("p", { className: "text-[10.5px] text-muted-foreground/80" }, "Delay in ms per index. Capped so long lists never feel slow to arrive. MessageBubble uses (0.03, 0.24), reply cards step 0.05."));
}
// ── Live list: replay the entrance, or let a reply arrive and reorder ──
function ago(ms) { return new Date(Date.now() - ms).toISOString(); }
function chat(id, name, channel, preview, at, unread) {
return {
id: id, account_id: "acc-1", prospect_id: id + "-p", workflow_execution_state_id: null, channel: channel, chat_id: "prov-" + id,
title: null, is_group: false, is_archived: false, unread_count: unread || 0, last_message_at: at, last_message_preview: preview,
metadata: {}, share_override: "inherit", reply_state: unread ? "awaiting_us" : "awaiting_them", workspace_id: "ws-1",
created_at: ago(20 * DAY), updated_at: at,
prospect: { id: id + "-p", full_name: name, first_name: null, last_name: null, email: null, linkedin_url: null, photo_url: null },
account: { id: "acc-1", name: "Mathieu Laurent", email: "mathieu@digitalcrew.ai" }, campaign: null
};
}
function seed() {
return [
chat("c1", "Claire Dubois", "linkedin", "Thursday at 10 works. Can you send the invite?", ago(6 * MIN), 2),
chat("c2", "Léa Martin", "whatsapp", "You: Perfect, I'll share the deck tonight.", ago(48 * MIN), 0),
chat("c3", "Marcus Reinholt", "email", "Re: Pipeline review, numbers attached", ago(2 * 60 * MIN), 1),
chat("c4", "Tomás Alvarez", "email", "You: Following up on the Brightline pilot", ago(DAY + 3 * 60 * MIN), 0),
chat("c5", "Priya Raman", "linkedin", "You: Great meeting you at SaaStr", ago(6 * DAY), 0)
];
}
var REPLIES = ["Sounds good, send over the pilot terms.", "Can we move it to 3pm?", "Looping in our Head of Ops.", "Yes, let's do it."];
function Demo() {
var l = React.useState(seed), list = l[0], setList = l[1];
var k = React.useState(0), key = k[0], setKey = k[1];
var n = React.useState(0), nth = n[0], setNth = n[1];
var a = React.useState("c2"), active = a[0], setActive = a[1];
function arrive() {
setList(function (prev) {
var last = prev[prev.length - 1];
var moved = Object.assign({}, last, { last_message_at: new Date().toISOString(), last_message_preview: REPLIES[nth % REPLIES.length],
unread_count: (last.unread_count || 0) + 1, reply_state: "awaiting_us" });
return [moved].concat(prev.slice(0, -1));
});
setNth(nth + 1);
}
return h("div", { className: "flex flex-col gap-3" },
h("div", { className: "flex items-center gap-2" },
h(DC.Button, { size: "sm", variant: "outline", onClick: function () { setList(seed()); setKey(key + 1); } }, h(I.History), "Replay entrance"),
h(DC.Button, { size: "sm", onClick: arrive }, h(I.MessageSquare), "New reply arrives")),
h("div", { key: key, className: "flex flex-col gap-0.5 rounded-2xl border border-border/60 bg-card p-2 shadow-sm" },
list.map(function (c, i) {
return h(DC.ChatListItem, {
key: c.id, chat: c, index: i, isActive: active === c.id, selectionMode: false, isChecked: false,
unreadCount: c.unread_count, layoutDependency: list,
onSelect: function () {
setActive(c.id);
setList(function (prev) { return prev.map(function (x) { return x.id === c.id ? Object.assign({}, x, { unread_count: 0 }) : x; }); });
}
});
})),
h("p", { className: "text-[11px] leading-snug text-muted-foreground" },
"Rows enter with SPRING_ENTER from 14px below, 98% scale and 4px blur, staggered; a new reply glides the row to the top on SPRING_GLIDE and the unread badge pops on SPRING_POP. Under reduced motion rows simply appear."));
}
function App() {
return h("div", { className: "flex flex-col gap-4" },
h("div", null,
h("h2", { className: "text-lg font-semibold tracking-tight" }, "Unibox motion"),
h("p", { className: "text-sm text-muted-foreground" }, "Every Unibox surface moves on the same springs, so the whole inbox feels like one physical material.")),
h("div", { className: "grid grid-cols-[1fr_360px] gap-6" },
h("div", { className: "grid grid-cols-2 content-start gap-3" },
SPRINGS.map(SpringCard), h(FadeCard), h(StaggerCard)),
h(Demo)));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>