Exclusivebeta
Remembered — <mv-remembered>
Discloses the lasting side effects of a choice, the moment it is made, without interrupting. When a declared control changes (data-remember="Invoices from now on will be in {value}" on a select, a switch, a radio group, any form-associated element), when a declared button is clicked, or when app code calls remember({ who, what, why, undo }), a small corner note says who keeps the change (Billing, Your team (8 people), Anyone with the link), what it now means and why it matters, with Undo and Details. Notes stack briefly (paused while hovered or focused), then fold into a persistent “Consequences” pill whose ledger lists every lasting choice of the session, grouped by who keeps it, each with its time and its own Undo. Changing the same control again updates its entry in place (“Updated · 2 changes”); putting it back by hand clears it. Undo on a form control restores the previous value and dispatches input/change so the app saves it like any user edit; for actions and API entries the app reverts through a cancelable mv-undo with waitUntil(promise), and irreversible effects (data-remember-undo="false") are flagged “Can’t be undone”. Per-state messages for switches (data-remember-on / -off), {value} / {old} / {label} placeholders, a cancelable mv-remember that can veto or rewrite the note, silent entries to restore earlier choices, a viewport corner in the top layer or contained in a region.
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-remembered>) |
| Status | beta |
| Also installs | button |
| Keywords | exclusive, culture, feedback, consequences, side-effects, undo, ledger, history, settings, disclosure, notification, audit, live-region, non-blocking |
When to use
- A settings change reaches beyond the current screen: teammates are notified, future invoices or payouts change, a link becomes public
- Users flip several account or workspace options in a row and need one place to review and revert what they changed this session
- An action has a consequence people rarely read in the fine print, such as leaving a team, muting someone or resending an invite
- Some effects cannot be undone and must be disclosed as such, right when they happen, without a blocking dialog
Avoid when
- The feedback is a passing confirmation (Saved, Copied, Upload complete) with no effect beyond the screen → use Toast instead
- The user must see what a change will affect before committing it, not after → use Butterfly instead
- The change needs explicit consent before anything happens (deleting a project, a charge) → use Dialog instead
Install
node scripts/add.mjs remembered --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["remembered"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, components/remembered/remembered.js, components/remembered/remembered.css, components/button/button.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="rm-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#rm-demo { display:grid; grid-template-columns:minmax(0,1fr); gap:.875rem; align-content:start }
#rm-demo .rm-app { display:grid; grid-template-columns:10rem minmax(0,1fr); min-height:36rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#rm-demo .rm-side { display:grid; align-content:start; gap:.125rem; padding:1rem .75rem; border-inline-end:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#rm-demo .rm-brand { display:flex; align-items:center; gap:.5rem; margin:0 0 1rem .375rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
#rm-demo .rm-logo { display:grid; place-items:center; width:1.625rem; height:1.625rem; border-radius:var(--mv-radius-md); background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#rm-demo .rm-logo svg { width:.9375rem; height:.9375rem }
#rm-demo .rm-side-label { margin:.5rem 0 .25rem .5rem; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#rm-demo .rm-side a { display:block; padding:.375rem .5rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
#rm-demo .rm-side a[aria-current] { background:var(--mv-bg-emphasis); color:var(--mv-fg) }
#rm-demo .rm-main { display:grid; align-content:start; gap:1rem; max-width:30rem; padding:1.375rem 1.5rem 1.75rem }
#rm-demo .rm-main h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
#rm-demo .rm-lede { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#rm-demo .rm-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#rm-demo .rm-card > h4 { margin:0; padding:.75rem 1rem; border-bottom:1px solid var(--mv-border); font-size:.8125rem; font-weight:600 }
#rm-demo .rm-row { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:.75rem 1rem }
#rm-demo .rm-row + .rm-row { border-top:1px solid var(--mv-border) }
#rm-demo .rm-row-text { display:grid; gap:.125rem; min-width:0 }
#rm-demo .rm-row-text b { font-size:.8125rem; font-weight:550 }
#rm-demo .rm-row-text span { color:var(--mv-fg-muted); font-size:.75rem }
#rm-demo .rm-row .mv-select { width:10.5rem; flex:none }
#rm-demo fieldset.rm-row { margin:0; border:0; border-top:1px solid var(--mv-border); min-inline-size:0; flex-wrap:wrap }
#rm-demo fieldset.rm-row legend { float:left; padding:0 }
#rm-demo .rm-radios { display:flex; gap:1rem; flex-wrap:wrap }
#rm-demo .rm-radios .mv-choice { font-size:.8125rem }
#rm-demo .rm-who { display:flex; align-items:center; gap:.625rem; min-width:0 }
#rm-demo .rm-face { display:grid; place-items:center; flex:none; width:1.875rem; height:1.875rem; border-radius:50%; background:var(--mv-bg-emphasis); font-size:.6875rem; font-weight:600 }
#rm-demo .rm-row .mv-button { flex:none }
#rm-demo .rm-controls { display:flex; align-items:center; justify-content:space-between; gap:.625rem 1rem; flex-wrap:wrap; padding:.75rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#rm-demo .rm-log { margin:0; flex:1 1 18rem; min-width:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#rm-demo .rm-actions { display:flex; align-items:center; gap:.75rem; flex-wrap:wrap }
#rm-demo .rm-actions .mv-choice { font-size:.8125rem }
@media (max-width:46rem) {
#rm-demo .rm-app { grid-template-columns:minmax(0,1fr); min-height:44rem }
#rm-demo .rm-side { display:none }
#rm-demo .rm-main { padding:1rem }
#rm-demo .rm-row { flex-wrap:wrap }
}
</style>
<mv-remembered id="rm-el" contained who="Your account" style="--mv-remembered-width:19.75rem;--mv-remembered-offset:.875rem">
<div class="rm-app">
<nav class="rm-side" aria-label="Settings">
<span class="rm-brand">
<span class="rm-logo" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5l8 7 8-7v14"/></svg></span>
Meridian
</span>
<span class="rm-side-label">Workspace</span>
<a href="#" aria-current="page">General</a>
<a href="#">Members</a>
<a href="#">Billing</a>
<a href="#">Integrations</a>
<span class="rm-side-label">Account</span>
<a href="#">Profile</a>
<a href="#">Notifications</a>
</nav>
<div class="rm-main">
<div>
<h3>Workspace settings</h3>
<p class="rm-lede">Northwind Studio · Team plan · 8 members</p>
</div>
<section class="rm-card" aria-labelledby="rm-billing">
<h4 id="rm-billing">Billing</h4>
<div class="rm-row">
<label class="rm-row-text" for="rm-currency"><b>Default currency</b><span>Used for new invoices and payouts</span></label>
<select class="mv-select" id="rm-currency"
data-remember="Invoices from now on will be in {value}"
data-remember-who="Billing"
data-remember-why="Invoices already sent stay in {old}; your accountant’s exports switch too.">
<option value="USD" data-remember-value="USD" selected>USD · US dollar</option>
<option value="EUR" data-remember-value="EUR">EUR · Euro</option>
<option value="GBP" data-remember-value="GBP">GBP · Pound sterling</option>
<option value="JPY" data-remember-value="JPY">JPY · Japanese yen</option>
</select>
</div>
<label class="rm-row">
<span class="rm-row-text"><b>Auto-renew</b><span>Team plan · renews Oct 15, 2026</span></span>
<input type="checkbox" role="switch" class="mv-switch" checked
data-remember-on="Your Team plan renews automatically on Oct 15, 2026"
data-remember-why-on="The card ending in 4242 will be charged $1,188."
data-remember-off="Your Team plan ends on Oct 15, 2026"
data-remember-why-off="After that date the workspace turns read-only. Nothing is deleted."
data-remember-who="Billing">
</label>
</section>
<section class="rm-card" aria-labelledby="rm-sharing">
<h4 id="rm-sharing">Sharing & notifications</h4>
<label class="rm-row">
<span class="rm-row-text"><b>Weekly digest for the team</b><span>A Monday summary of every project</span></span>
<input type="checkbox" role="switch" class="mv-switch" checked
data-remember-off="Your team stops getting the Monday digest"
data-remember-why-off="Each member is told you turned it off, starting next Monday."
data-remember-on="Your team gets the Monday digest again"
data-remember-who="Your team (8 people)">
</label>
<fieldset class="rm-row"
data-remember="{value} can now open “Q4 roadmap”"
data-remember-who="Link sharing"
data-remember-why="Anyone who already has the link keeps it; forwarded copies work too.">
<legend class="rm-row-text"><b>Who can open “Q4 roadmap”</b><span>Link access for this document</span></legend>
<span class="rm-radios">
<label class="mv-choice"><input type="radio" class="mv-radio" name="rm-link" value="members" data-remember-value="Only workspace members" checked> Members</label>
<label class="mv-choice"><input type="radio" class="mv-radio" name="rm-link" value="anyone" data-remember-value="Anyone with the link"> Anyone with the link</label>
</span>
</fieldset>
</section>
<section class="rm-card" aria-labelledby="rm-members">
<h4 id="rm-members">Membership</h4>
<div class="rm-row">
<span class="rm-who"><span class="rm-face" aria-hidden="true">DT</span><span class="rm-row-text"><b>Design team</b><span>6 members · 14 shared files</span></span></span>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="rm-leave"
data-remember="Design team members will see that you left"
data-remember-who="Design team"
data-remember-why="You lose access to its 14 shared files until someone adds you back.">Leave team</button>
</div>
<div class="rm-row">
<span class="rm-who"><span class="rm-face" aria-hidden="true">LF</span><span class="rm-row-text"><b>Lena Fischer</b><span>Invited Sep 19 · not joined yet</span></span></span>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm"
data-remember="A new invitation is on its way to [email protected]"
data-remember-who="Lena Fischer"
data-remember-why="Emails can’t be recalled; the previous link stops working."
data-remember-undo="false">Resend invite</button>
</div>
</section>
</div>
</div>
</mv-remembered>
<div class="rm-controls">
<p class="rm-log" id="rm-log" aria-live="off">Change a setting: its lasting effect is noted, then kept in Consequences.</p>
<span class="rm-actions">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="rm-keep"> Keep notes until dismissed</label>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="rm-reset">Clear ledger</button>
</span>
</div>
<script type="module">
await customElements.whenDefined("mv-remembered");
const el = document.getElementById("rm-el");
const log = document.getElementById("rm-log");
const leave = document.getElementById("rm-leave");
const currency = document.getElementById("rm-currency");
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
const say = (text) => { log.textContent = `${time()} · ${text}`; };
// Choices made earlier in the session (e.g. restored from the server): straight into the ledger.
const seed = () => {
const now = Date.now();
el.remember({ id: "private-default", who: "Your team (8 people)", what: "New projects are private by default", why: "Existing projects keep their visibility.", undoable: true, undo: () => new Promise((r) => setTimeout(r, 500)), silent: true, at: now - 26 * 60_000 });
el.remember({ id: "tax-id", who: "Billing", what: "Invoices now show VAT ID DE 811 907 980", why: "Required for EU reverse charge.", undoable: false, silent: true, at: now - 41 * 60_000 });
};
// The app owns the revert of an action: handle mv-undo (waitUntil for the server round trip).
leave.addEventListener("click", () => {
const left = leave.dataset.left === "true";
leave.dataset.left = String(!left);
leave.textContent = left ? "Leave team" : "Rejoin team";
if (!left) leave.setAttribute("data-remember", "Design team members will see that you left");
});
el.addEventListener("mv-undo", (e) => {
say(`mv-undo · ${e.detail.what}`);
if (e.detail.source === leave) {
e.detail.waitUntil(new Promise((r) => setTimeout(r, 450)).then(() => {
leave.dataset.left = "false";
leave.textContent = "Leave team";
}));
}
});
el.addEventListener("mv-remember", (e) => {
// Rejoining is the undo of leaving, not a new lasting change.
if (e.detail.source === leave && leave.dataset.left !== "true") { e.preventDefault(); const entry = el.entries.find((x) => x.source === leave && x.status === "active"); if (entry) el.undo(entry.id, "rejoin"); return; }
say(`mv-remember · ${e.detail.who}: ${e.detail.what}`);
});
el.addEventListener("mv-ledger", (e) => say(`mv-ledger · ${e.detail.open ? "opened" : "closed"}`));
document.getElementById("rm-keep").addEventListener("change", (e) => { el.duration = e.target.checked ? "none" : "6s"; });
document.getElementById("rm-reset").addEventListener("click", () => {
el.open = false;
el.clear();
seed();
say("Ledger cleared.");
});
seed();
// Show the mechanic right away: the currency was just switched to EUR.
currency.value = "EUR";
currency.dispatchEvent(new Event("change", { bubbles: true }));
</script>
</div>Cultural reference
The Walking Dead — Telltale Games (2012, game). After a meaningful choice, a discreet corner notice tells the player, without pausing the scene, that another character has taken note and the decision will carry forward, and those choices are summed up at the end of each episode. In the UI, a non-blocking corner note names who keeps a lasting change and what it now means, then folds into a session ledger of consequences, grouped by who keeps them, where each one can still be undone.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
who | string | Your account | Default “who keeps it” for entries that do not name one (ledger group and note eyebrow). |
duration | time ("6s", "4000") | "none" | 6s | How long a note stays before folding into the ledger (minimum 2 s). The clock pauses while any note is hovered or focused and while the tab is hidden; "none" keeps notes until dismissed. Changing it restarts the visible notes' clocks. |
max | number | 3 | Visible notes at once; older ones fold into the ledger early. |
placement | bottom-end | bottom-start | top-end | top-start | bottom-end | Corner of the viewport (or of the element when contained). Logical sides: end is right in LTR, left in RTL. |
contained | boolean | false | Keep notes and the ledger inside the element's box (position: relative) instead of the viewport corner. Without it, the layer is rendered in the top layer (popover="manual") so no stacking context can clip it. |
label | string | Consequences | Name of the pill and the ledger. |
hotkey | string | "none" | Alt+M | Moves focus to the newest note, or opens the ledger when no note is showing (press again to close and return). Matched on the physical key, so it works with any layout. Set "none" on secondary instances. |
locale | BCP 47 tag | en-US | Time format in the ledger. |
open | boolean | false | The ledger is open (reflected). Opening folds every visible note into it. |
data-remember | template | On a control, a container of controls (fieldset, radio group) or a button / link: the message shown when it changes or is clicked. Placeholders: {value} (new value's label), {old} (value before the first change), {label} (the control's label). Controls report through change (and mv-change for form-associated elements); buttons and links through click. | |
data-remember-on / data-remember-off | template | On a lone checkbox or switch: one message per state (fall back to data-remember). A state with no message is not remembered. | |
data-remember-who | template | Who keeps the change: a person, a team, a service (“Billing”), the public. Groups the ledger. | |
data-remember-why / -why-on / -why-off | template | One short line on why it matters (optional). | |
data-remember-href | URL | Adds a “Learn more” link to the ledger entry. | |
data-remember-undo | "false" | The effect can't be undone: no Undo button, flagged “Can’t be undone” in the note and the ledger. | |
data-remember-value | string | On an <option> or a radio: the label used for {value} / {old} (default: the option's label or the radio's label). | |
data-remember-label | string | On the declarer: the text used for {label}. |
Properties
| Name | Type | Description |
|---|---|---|
entries | Entry[] | Read-only, chronological: { id, who, what, why, href, source, value, oldValue, status ("active" | "pending" | "reverted"), undoable, changes, at, updatedAt }. |
reverting | boolean | True while Undo restores form controls, i.e. during the input/change events it dispatches. |
who / duration / max / placement / contained / label / hotkey / locale / open | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
remember({ who, what, why, href, undo, undoable, id, source, value, oldValue, silent, at }) → id | null | Records a lasting change from app code. undo(entry) may return a promise (pending state, error kept in place on rejection); undoable defaults to Boolean(undo). Same id = the entry is updated in place. silent: true adds it straight to the ledger without a note or announcement (restore earlier choices with at: timestamp). Returns null when mv-remember is canceled. Calls made before the element is connected are queued. |
remember(opts, { target? }) | Also exported from remembered.js (and MvRemembered.remember): uses the first page-level <mv-remembered>, or any, and creates one on <body> when there is none. |
undo(id, reason?) → Promise<boolean> | Reverts one entry, as its Undo button does. |
forget(id) / clear() | Removes one or every entry without reverting anything. |
showLedger(id?) / hideLedger() | Opens the ledger (focused on one entry, highlighted) or closes it and returns focus. |
Events
| Name | Description |
|---|---|
mv-remember | Before a note shows. detail: { id, who, what, why, href, source, value, oldValue, undoable, update (true when an existing entry changes again), silent }. Cancelable: preventDefault() vetoes the note and the entry; who, what and why can be rewritten on detail before it shows. |
mv-undo | Before an entry is reverted. detail: the entry plus reason ("note" | "ledger" | "api" | your own) and waitUntil(promise). Cancelable: preventDefault() keeps the change. For buttons and links the revert is yours: do it here (async work through waitUntil); form controls are restored by the component once every promise resolves. |
mv-undo-error | A waitUntil promise or the undo function rejected. detail: the entry, reason and error. The entry stays active and shows the failure. |
mv-ledger | The ledger opened or closed. detail: { open }. |
Content structure
| Name | Description |
|---|---|
(content) | The region whose controls are watched (a settings page, a form, a whole app). An empty <mv-remembered> watches the whole document, minus regions that have their own <mv-remembered>. The notes, pill and ledger are appended in a layer the component owns. |
CSS classes
| Name | Description |
|---|---|
mv-remembered-layer | Corner layer (popover="manual" unless contained); data-placement, data-empty. |
mv-remembered-note / -who / -tag / -what / -why / -actions / -timer | A note; data-state="active | pending | undone", data-persistent when it has no clock. |
mv-remembered-pill / -count | The folded ledger trigger (aria-expanded, aria-keyshortcuts). |
mv-remembered-ledger / -group / -group-title / -list / -entry | The ledger panel; entries carry data-status and data-flash while highlighted. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-remembered-accent | var(--mv-accent) | Rail, timer, dots and count. |
--mv-remembered-ink | var(--mv-accent-fg) | Accent-colored text and icons. |
--mv-remembered-width | 23rem | Width of notes and ledger (capped by the viewport or the element). |
--mv-remembered-offset | var(--mv-space-4) | Distance from the corner. |
Accessibility
Nothing is ever interrupted: a note is not a dialog, never takes focus and never traps it; its content is announced once through a separate polite live region (“Billing: Invoices from now on will be in EUR. Invoices already sent stay in USD…”, plus “This can’t be undone.” when relevant and, the first time, the hotkey hint). Keyboard: Alt+M (configurable, exposed as aria-keyshortcuts on the pill) moves focus to the newest note, where Tab reaches Undo, Details and Dismiss and Escape folds the note into the ledger, moving focus to the next note or back where it was; with no note showing, the hotkey opens the ledger (and closes it again). The pill is a disclosure button (aria-expanded, aria-controls) and the ledger a labelled region with one heading per group and a real list of entries, each Undo button described by its entry; opening moves focus into the ledger, Escape or the close button closes it and returns focus to the pill, an outside click closes it without moving focus. Timing (WCAG 2.2.1): the clock pauses while any note is hovered or focused and while the tab is hidden, duration is adjustable and "none" disables it, and nothing is lost when a note folds, since the same entry stays in the ledger for the whole session. When a note holding focus leaves, focus goes to the next note, back to where it came from, or to the pill, never to <body>. Undo restores controls with real input/change events, so the app's own validation and saving run. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no fly-to-pill, no bump, no timer sweep; notes simply appear and disappear. Forced colors: system borders, Highlight rail, timer and count, outline focus rings.