# UniboxMotion

> The Unibox's shared motion vocabulary: every surface moves on the same few springs, so the whole inbox feels like one physical material.

## Facts

- **Product**: Max
- **Family**: Motion
- **Kind**: Showcase page (tokens and patterns)
- **Source**: `max-agent: src/features/unibox/ui/lib/motion.ts`
- **Live preview**: /design/components/unibox-motion/preview.html
- **Page**: /design/components/unibox-motion

## 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`](/design/components/chat-list-item.md) layout and the [`ChannelSegments`](/design/components/channel-segments.md) 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`](/design/components/message-bubble.md) 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.

## Example

```html
<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>
```

## More in Motion

- [ActionFocusSession](/design/components/action-focus-session.md): The focus timebox for one task in the actions cockpit: a live billable clock with approve, start, pause, run and done, and a burst of sparkles on completion.
- [CardResize](/design/components/card-resize.md): A container that tweens its width and height when its content changes (`.t-resize`).
- [ChapterScrubber](/design/components/chapter-scrubber.md): A rail of ticks, one per chapter, that swell like a dock under the pointer, with a card previewing the hovered chapter.
- [ContainerScroll](/design/components/container-scroll.md): A hero block where a device-like frame starts tilted back in 3D and straightens as the page scrolls, with the title drifting up.
- [FeatureSlideshow](/design/components/feature-slideshow.md): A step-by-step feature explainer: an accordion of steps on the left that advances on a timer, a progress line on the open step, and a large media slot on the right.
- [FirstBentoAnimation](/design/components/first-bento-animation.md): A self-playing chat vignette for the bento grid: a user asks for a meeting slot, the Digital Crew avatar shows typing dots, then the reply streams in as a `Reasoning` block.
- [FourthBentoAnimation](/design/components/fourth-bento-animation.md): A scheduling vignette for the bento grid: a week ruler, three task bars that spring into place, and a time cursor that follows the pointer.
- [HyperText](/design/components/hyper-text.md): A monospaced, uppercase text effect: letters scramble through random characters, then lock in from left to right.
- [InfiniteSlider](/design/components/infinite-slider.md): A JavaScript-driven endless strip (Framer Motion) that renders its children twice and slides them in a loop, optionally slowing on hover.
- [JoyfulTaskCheckbox](/design/components/joyful-task-checkbox.md): A task checkbox with a small, restrained completion moment: one soft bloom, two sparks, then stillness.
- [Lottie](/design/components/lottie.md): The lottie-react player, exported as-is, for the Lottie animations that ship with Crew OS (`animations/*.json`).
- [Marquee](/design/components/marquee.md): An endless CSS scroll of repeated content, horizontal or vertical, speed set with two CSS variables.
- [MotionScale](/design/components/motion-scale.md): The shared motion scale made visible: the same distance travelled on each duration token and each easing, with a Replay button.
- [NumberFlowCounter](/design/components/number-flow-counter.md): A small dark pill that shows one number at a time and rolls its digits to the next value every two seconds (NumberFlow).
- [OrbitingCircles](/design/components/orbiting-circles.md): Places its children on a circular orbit around the centre of the parent and spins them, with an optional faint ring for the path.
- [ShimmerText](/design/components/shimmer-text.md): A status line with a light band sweeping across its glyphs — Max's "Thinking…" and live tool lines (`.t-shimmer`).
- [SignatureGradient](/design/components/signature-gradient.md): The signature Digital Crew gradient — indigo → pink → amber (`dc-andre`, `dc-camille`, `dc-amber`) — in its four forms.
- [SkeletonReveal](/design/components/skeleton-reveal.md): A skeleton that cross-blurs into the loaded content, so the swap reads as one motion (`.t-skel`).
- [StreamingText](/design/components/streaming-text.md): Streamed words resolving through a soft cross-blur, one after another (`.t-stream-w` → `.is-in`).
- [ThirdBentoAnimation](/design/components/third-bento-animation.md): An insight vignette for the bento grid: a smooth area line draws itself, a dot pulses at its midpoint and a dark pill above it ticks through values.
