Design
llms.txt digitalcrew.tech

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.

MaxMotionShowcase page
UniboxMotion · live · 960px wideOpen

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 the ChatListItem layout and the ChannelSegments pill.
  • 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. MessageBubble uses (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#

GroupMotion
ProductMax
KindShowcase page (tokens and patterns, not a single export)
Sourcemax-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>