Exclusivebeta

While Away — <mv-while-away>

“While you were away…”: wraps any region (dashboard, feed, board), notices what changed while you were elsewhere (hidden tab or idle) and gets you back in context in 3 seconds. On return, a card summarizes the absence by tier (blockers, decisions, updates, each with an icon and a label), and every entry leads to the element it concerns; “Replay (3s)” plays the changes back in order: the old content reappears in place, then melts into the current one with a light sweep and a “42 minutes ago → now” tag, while off-screen elements get edge arrows. “Unseen” markers stay on each element until it has actually been looked at.

CategoryFeedback
TypeWeb Component (<mv-while-away>)
Statusbeta
Keywordsexclusive, feedback, resumption, catch-up, absence, idle, visibility, notification, tiered-notifications, unseen, changes, diff, replay, activity, dashboard, mutation-observer

When to use

Avoid when

Install

node scripts/add.mjs while-away --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["while-away"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/while-away/while-away.js, components/while-away/while-away.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="wa-demo" style="width:min(100%,58rem);margin-inline:auto">
  <style>
    #wa-demo .wa-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin:0 0 1rem }
    #wa-demo .wa-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #wa-demo .wa-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #wa-demo .wa-sim { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap }
    #wa-demo .wa-status { color:var(--mv-fg-muted); font-size:.75rem }
    #wa-demo mv-while-away[data-away] .wa-grid { filter:saturate(.4) opacity(.72); transition:filter .3s }
    #wa-demo .wa-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.875rem; transition:filter .3s }
    #wa-demo .wa-card { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #wa-demo .wa-card h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0 0 .75rem; font-size:.8125rem; font-weight:600 }
    #wa-demo .wa-card h4 small { color:var(--mv-fg-subtle); font-weight:500; font-size:.75rem }
    #wa-demo .wa-kpi .wa-k { color:var(--mv-fg-muted); font-size:.75rem }
    #wa-demo .wa-kpi strong { display:block; margin:.25rem 0 .125rem; font-size:1.625rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
    #wa-demo .wa-trend { font-size:.75rem; color:var(--mv-fg-muted) }
    #wa-demo .wa-trend[data-dir="up"] { color:color-mix(in oklab, var(--mv-success), var(--mv-fg) 30%) }
    #wa-demo .wa-tasks { grid-column:span 2 }
    #wa-demo .wa-side { display:grid; gap:.875rem; align-content:start }
    #wa-demo ul { list-style:none; margin:0; padding:0 }
    #wa-demo .wa-task { display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:.75rem; padding:.625rem .5rem; border-radius:var(--mv-radius-md) }
    #wa-demo .wa-task + .wa-task { border-top:1px solid var(--mv-border) }
    #wa-demo .wa-task b { display:block; font-weight:500; font-size:.875rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #wa-demo .wa-task span.wa-meta { color:var(--mv-fg-subtle); font-size:.75rem }
    #wa-demo .wa-build .wa-row { display:flex; align-items:center; justify-content:space-between; gap:.5rem; font-size:.8125rem }
    #wa-demo .wa-bar { height:6px; margin:.625rem 0 .5rem; border-radius:3px; background:var(--mv-bg-muted); overflow:hidden }
    #wa-demo .wa-bar i { display:block; height:100%; width:var(--p); border-radius:inherit; background:var(--mv-accent); transition:width .4s }
    #wa-demo .wa-build[data-state="success"] .wa-bar i { background:var(--mv-success) }
    #wa-demo .wa-build .wa-sub { color:var(--mv-fg-muted); font-size:.75rem }
    #wa-demo .wa-build .mv-button { margin-top:.75rem; width:100% }
    #wa-demo .wa-comment { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.625rem; padding:.5rem 0 }
    #wa-demo .wa-comment + .wa-comment { border-top:1px solid var(--mv-border) }
    #wa-demo .wa-comment p { margin:.125rem 0 0; font-size:.8125rem; line-height:1.45 }
    #wa-demo .wa-comment .wa-who { font-size:.75rem; font-weight:600 }
    #wa-demo .wa-comment .wa-who span { color:var(--mv-fg-subtle); font-weight:400 }
    #wa-demo .wa-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
    @media (max-width:46rem) {
      #wa-demo .wa-grid { grid-template-columns:minmax(0,1fr) }
      #wa-demo .wa-tasks { grid-column:auto }
    }
  </style>

  <div class="wa-top">
    <div>
      <h3>Website redesign · Sprint 14</h3>
      <p>Team dashboard · Nomad Studio</p>
    </div>
    <div class="wa-sim">
      <span class="wa-status" id="wa-status" aria-live="polite"></span>
      <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="wa-simulate">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
        Simulate a 45-min absence
      </button>
    </div>
  </div>

  <mv-while-away id="wa-dash" threshold="5000">
    <div class="wa-grid">
      <article class="wa-card wa-kpi" data-away-label="Conversion rate">
        <span class="wa-k">Conversion rate</span>
        <strong data-f="conv" data-away-key>3.2%</strong>
        <span class="wa-trend" data-f="conv-trend">flat over 7 days</span>
      </article>
      <article class="wa-card wa-kpi" data-away-label="Live visitors">
        <span class="wa-k">Live visitors</span>
        <strong data-f="live" data-away-key>128</strong>
        <span class="wa-trend" data-f="live-trend">peaked at 212 yesterday</span>
      </article>
      <article class="wa-card wa-kpi" data-away-label="Sprint budget">
        <span class="wa-k">Sprint budget</span>
        <strong>64%</strong>
        <span class="wa-trend">$18,400 of $28,750</span>
      </article>

      <section class="wa-card wa-tasks" data-away-label="Team tasks" aria-labelledby="wa-tasks-h">
        <h4 id="wa-tasks-h">Team tasks <small>Sprint 14 · 5 tasks</small></h4>
        <ul>
          <li class="wa-task" data-away-label="Homepage mockups" data-t="home">
            <div><b>Homepage mockups</b><span class="wa-meta">Figma · 12 screens</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Lena Moore"><span class="mv-avatar-fallback" aria-hidden="true">LM</span></span>
            <span class="mv-badge" data-variant="warning" data-shape="pill"><span class="mv-badge-dot"></span><span data-f="status" data-away-key>In review</span></span>
          </li>
          <li class="wa-task" data-away-label="Heading typography" data-t="type">
            <div><b>Heading typography</b><span class="wa-meta">2 typefaces in testing</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Kwame Boateng"><span class="mv-avatar-fallback" aria-hidden="true">KB</span></span>
            <span class="mv-badge" data-variant="info" data-shape="pill"><span class="mv-badge-dot"></span><span data-f="status" data-away-key>In progress</span></span>
          </li>
          <li class="wa-task" data-away-label="End-to-end tests" data-t="e2e">
            <div><b>End-to-end tests</b><span class="wa-meta">Checkout flow</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Sofia Nguyen"><span class="mv-avatar-fallback" aria-hidden="true">SN</span></span>
            <span class="mv-badge" data-variant="secondary" data-shape="pill"><span class="mv-badge-dot"></span><span data-f="status" data-away-key>To do</span></span>
          </li>
          <li class="wa-task" data-away-label="CMS migration">
            <div><b>CMS migration</b><span class="wa-meta">148 pages imported</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Isabel Moreno"><span class="mv-avatar-fallback" aria-hidden="true">IM</span></span>
            <span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span>Done</span>
          </li>
          <li class="wa-task" data-away-label="Accessibility audit">
            <div><b>Accessibility audit</b><span class="wa-meta">WCAG 2.2 · 86 criteria</span></div>
            <span class="mv-avatar" data-size="xs" data-variant="tinted" role="img" aria-label="Tom Grant"><span class="mv-avatar-fallback" aria-hidden="true">TG</span></span>
            <span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span>Done</span>
          </li>
        </ul>
      </section>

      <div class="wa-side">
        <section class="wa-card wa-build" data-away-label="Production build" data-state="running" aria-labelledby="wa-build-h">
          <h4 id="wa-build-h">Production build <small data-f="build-id" data-away-key>#482</small></h4>
          <div class="wa-row"><span data-f="build-state" data-away-key>Running · step 3 of 5</span><span data-f="build-pct">68%</span></div>
          <div class="wa-bar"><i data-f="build-bar" style="--p:68%"></i></div>
          <span class="wa-sub" data-f="build-sub">Started by Sofia · main branch</span>
          <button class="mv-button" data-size="sm" type="button" id="wa-deploy" hidden>Approve deployment</button>
        </section>

        <section class="wa-card" data-away-label="Discussion" aria-labelledby="wa-talk-h">
          <h4 id="wa-talk-h">Discussion <small data-f="talk-count">2 messages</small></h4>
          <ul data-f="thread" data-away-key>
            <li class="wa-comment">
              <span class="mv-avatar" data-size="xs" data-variant="tinted" aria-hidden="true"><span class="mv-avatar-fallback">LM</span></span>
              <div><span class="wa-who">Lena Moore <span>· 9:12 AM</span></span><p>The mockups are ready for review.</p></div>
            </li>
            <li class="wa-comment">
              <span class="mv-avatar" data-size="xs" data-variant="tinted" aria-hidden="true"><span class="mv-avatar-fallback">KB</span></span>
              <div><span class="wa-who">Kwame Boateng <span>· 9:40 AM</span></span><p>Still comparing two typefaces for the headings.</p></div>
            </li>
          </ul>
        </section>
      </div>
    </div>
  </mv-while-away>
  <p class="wa-hint">Leave this tab for more than 5 seconds: the team keeps working while you're gone · markers fade once each item has been seen</p>

  <script type="module">
    const wa = document.getElementById("wa-dash");
    await customElements.whenDefined("mv-while-away");
    const btn = document.getElementById("wa-simulate");
    const status = document.getElementById("wa-status");
    const $ = (sel, root = wa) => root.querySelector(sel);
    const f = (name, root = wa) => $(`[data-f="${name}"]`, root);
    const task = (t) => $(`[data-t="${t}"]`);
    const setTask = (t, text, variant, tier) => {
      const li = task(t);
      f("status", li).textContent = text;
      li.querySelector(".mv-badge").dataset.variant = variant;
      if (tier) li.dataset.awayTier = tier; else delete li.dataset.awayTier;
    };
    const build = $(".wa-build");
    const talk = $("[data-away-label='Discussion']");
    const setBuild = ({ id, state, label, pct, sub, deploy }) => {
      build.dataset.state = state;
      if (state === "success") build.dataset.awayTier = "done"; else delete build.dataset.awayTier;
      f("build-id").textContent = id;
      f("build-state").textContent = label;
      f("build-pct").textContent = `${pct}%`;
      f("build-bar").style.setProperty("--p", `${pct}%`);
      f("build-sub").textContent = sub;
      document.getElementById("wa-deploy").hidden = !deploy;
    };
    const comment = (initials, who, time, text) => {
      const li = document.createElement("li");
      li.className = "wa-comment";
      const av = document.createElement("span");
      av.className = "mv-avatar"; av.dataset.size = "xs"; av.dataset.variant = "tinted"; av.setAttribute("aria-hidden", "true");
      const fb = document.createElement("span"); fb.className = "mv-avatar-fallback"; fb.textContent = initials; av.append(fb);
      const body = document.createElement("div");
      const w = document.createElement("span"); w.className = "wa-who"; w.append(who, Object.assign(document.createElement("span"), { textContent: ` · ${time}` }));
      const p = document.createElement("p"); p.textContent = text;
      body.append(w, p); li.append(av, body);
      return li;
    };
    const thread = f("thread");
    const initialComments = thread.children.length;

    // Back to the "before" dashboard (not observed: nobody is away).
    const reset = () => {
      f("conv").textContent = "3.2%"; f("conv-trend").textContent = "flat over 7 days"; delete f("conv-trend").dataset.dir;
      f("live").textContent = "128"; f("live-trend").textContent = "peaked at 212 yesterday";
      setTask("home", "In review", "warning"); setTask("type", "In progress", "info"); setTask("e2e", "To do", "secondary");
      setBuild({ id: "#482", state: "running", label: "Running · step 3 of 5", pct: 68, sub: "Started by Sofia · main branch", deploy: false });
      while (thread.children.length > initialComments) thread.lastElementChild.remove();
      f("talk-count").textContent = "2 messages";
      delete talk.dataset.awayTier;
    };

    // What happens on screen while you are gone.
    const script = [
      () => setBuild({ id: "#483", state: "success", label: "Passed · 5 steps", pct: 100, sub: "4 min 12 s · 312 tests passed", deploy: true }),
      () => setTask("home", "Done", "success", "done"),
      () => { f("conv").textContent = "3.6%"; const t = f("conv-trend"); t.textContent = "+0.4 pt since this morning"; t.dataset.dir = "up"; },
      () => { thread.append(comment("IM", "Isabel Moreno", "10:26 AM", "We need to pick one of the two logos before Thursday. Do you prefer option A?")); f("talk-count").textContent = "3 messages"; talk.dataset.awayTier = "decision"; },
      () => setTask("type", "Needs approval", "warning", "decision"),
      () => wa.note({ label: "Deployment is waiting for your approval", detail: "Build #483 is ready for production", tier: "blocker", target: build }),
      () => setTask("e2e", "In progress", "info"),
      () => { f("live").textContent = "342"; f("live-trend").textContent = "new daily peak"; },
    ];

    let running = false;
    const simulate = async (gap) => {
      if (running) return;
      running = true;
      btn.disabled = true;
      wa.markSeen();
      reset();
      wa.away({ since: Date.now() - 45 * 60000 });
      status.textContent = gap ? "You're away… the team keeps going without you" : "";
      for (const step of script) { step(); await new Promise((r) => setTimeout(r, gap)); }
      wa.back();
      status.textContent = "";
      btn.disabled = false;
      running = false;
    };
    btn.addEventListener("click", () => simulate(320));

    // Real tab switch: the team keeps going while the page is hidden.
    wa.addEventListener("mv-away", (e) => {
      if (e.detail.reason !== "hidden") return;
      setTimeout(() => {
        if (!wa.isAway) return;
        f("live").textContent = String(300 + Math.round(Math.random() * 90));
        f("live-trend").textContent = "updated just now";
      }, 1200);
      setTimeout(() => {
        if (!wa.isAway) return;
        const now = new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
        thread.append(comment("SN", "Sofia Nguyen", now, "End-to-end tests are passing on staging."));
        f("talk-count").textContent = `${thread.children.length} messages`;
        wa.note({ label: "Sofia needs your go-ahead to ship to staging", tier: "decision", target: talk });
      }, 2400);
    });
    wa.addEventListener("mv-return", (e) => console.info("mv-return", `${Math.round(e.detail.duration / 1000)}s`, e.detail.changes.map((c) => `${c.tier}: ${c.label}`)));
    document.getElementById("wa-deploy").addEventListener("click", () => {
      setBuild({ id: "#483", state: "success", label: "Deployment started", pct: 100, sub: "Rolling out to production", deploy: false });
      const blocker = wa.changes.find((c) => c.tier === "blocker");
      if (blocker) wa.markSeen(blocker);
    });

    // First sight: you just got back from a 45-minute meeting.
    simulate(40);
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
idlenumber (ms)60000Inactivity (no pointer, keyboard, scroll or focus) after which the user counts as away. 0 turns idle detection off (a hidden tab is still detected). Watching starts quietly after 10s of calm so nothing is missed, and is dropped at the first gesture.
thresholdnumber (ms)20000Minimum length of an absence for it to count: below it, changes are ignored (notes are kept).
followbooleanfalseDuring the replay, scrolls to each off-screen element instead of showing an edge arrow.
observe-attributesstringdata-state, data-status, aria-*, value, hidden, src, datetimeWatched attributes (comma- or space-separated). “aria-*” covers the common ARIA states; add “class” explicitly. Text and added/removed nodes are always watched.
labelstringWhile you were awayTitle of the return card.
dwellnumber (ms)1000Time on screen (at least 60% of the element, or half a screen of it) before an element counts as seen. Counting starts 1.5s after the return.
data-away-labelstringOn an element inside the region: makes it a “change unit” with this label. Otherwise: the nearest ancestor among li, tr, article, figure, fieldset or a titled section (label: its heading, aria-label or the start of its text).
data-away-tierblocker | decision | done | infoTier of the changes detected in this element, read at return time (the app can set it along with the change). Default: info.
data-away-keybooleanMarks the part(s) whose text sums up the unit (a status, a value): the “before → after” uses only them.
data-away-ignorebooleanSubtree that is never observed (clocks, decorative counters…).

Properties

NameTypeDescription
changes{ id, kind, label, tier, time, seen, element, before, after }[]Pending changes, sorted by importance then chronologically (read-only). before / after: summarized text before and after.
isAwaybooleanTrue during a confirmed absence.

Methods

NameDescription
away({ since? })Starts an absence now (snapshot of the region, observation). since backdates the start: change times are then spread over the simulated span (demos, tests).
back()Ends the absence and shows the card if anything changed. Returns the new changes.
note({ label, tier?, target?, detail?, time? })Semantic event pushed by the app (“Deployment is waiting for your approval”). tier: blocker | decision | done | info; target: element or selector the entry leads to. During an absence the note waits for the return; otherwise a blocker opens the card right away, other tiers join the open card or the next return.
replay()Replays the changes in ~3s (calling again stops it). Reduced motion: shows the before / after list.
markSeen(change?)Marks one change (object or id) as seen; with no argument, everything, and closes the card.

Events

NameDescription
mv-awayConfirmed absence. detail: { reason: "hidden" | "idle" | "manual", since }.
mv-returnReturn. detail: { duration, reason, changes }.
mv-seenChanges were seen (on screen, by click, by the replay or markSeen). detail: { changes, remaining }.

Content structure

NameDescription
(content)Any region. The card is inserted at the top (sticky: it stays visible in a tall region and folds to its header once stuck); markers and the replay are drawn in an overlay layer, without touching the app's DOM.

CSS classes

NameDescription
mv-while-away-cardReturn card (role=region): header, per-tier counts, entries, actions.
mv-while-away-entry / -tierEntry (button) and its tier chip (icon + label). data-tier, data-seen.
mv-while-away-mark / -dot“Unseen” halo and badge set on a changed element.
mv-while-away-flash / -stamp / -edge / -ghostReplay: halo and sweep, time tag, edge arrow, ghost of the old content.
mv-while-away-diffsBefore / after list (reduced motion).

CSS variables

NameDefaultDescription
--mv-while-away-offsetvar(--mv-space-3)Distance from the top of the screen when the card is stuck (height of a fixed header, for example).
--mv-while-away-bgvar(--mv-surface-raised)Card background.
--mv-while-away-blockervar(--mv-danger)Blocker tier color.
--mv-while-away-decisionvar(--mv-warning)Decision tier color.
--mv-while-away-donevar(--mv-success)Done tier color.
--mv-while-away-infovar(--mv-info)Update tier color.

Accessibility

The card is a named region (“While you were away”), announced through a polite live region (“While you were away (47 min): 1 blocker, 2 decisions, 5 updates.”); focus moves to it only after a tab switch, never after mere idling nor while you are typing. Entries are real buttons (role=list), sorted by importance, navigable with the arrows, Home and End; each one spells out the tier, the label, “Before: … Now: …”, its age and a “Seen / Unseen” state; activating it scrolls to the element, focuses it and marks it as seen. The replay is purely visual (aria-hidden) and announced when it starts and ends; Escape stops it. Markers, halos and arrows are aria-hidden, and a tier color is always paired with an icon and a label. Reduced motion (system or data-motion="reduce"): no animation, “Replay” becomes “Before / after” (aria-expanded) and opens the comparison list, markers are static. Performance: a single MutationObserver, active only during an absence, batched with no layout reads; the snapshot is cloned once at the start of the absence; one IntersectionObserver for “seen”; overlays recomputed at most once per frame (read then write); everything is released when the element is removed. Password fields are never copied.