Exclusivebeta

Totem — <mv-totem>

Environment indicator that makes a non-production environment impossible to mistake for production. A tiny faceted spinning top keeps spinning steadily in every environment that is not real (local, preview, staging, sandbox, QA, or production with a mode on such as test mode or impersonation) and, in production, wobbles, topples and rests on its side, quiet and neutral, shrunk to a glyph whose name slides out on hover. The environment comes from the env attribute or from ordered hostname rules (globs like .vercel.app, staging., localhost:6006 or /regex/, with sensible defaults and a fallback), each with its own label and tint (overridable per env with --mv-totem-tint). Click or Enter opens a compact non-modal popover: environment, mode, host, build, commit, API base, data source, since when (en-US date plus relative time), which rule detected it, a Copy details button for bug reports and slots for your own actions (Open production, Stop impersonating). Opening it in production flicks the top once more so it falls again. Optional page-edge frame with a labeled tab (viewport or any container) and document title prefix ([staging] Orders…) kept in place across router title changes, both only outside production. Topples once per tab session by default, so production never nags.

CategoryFeedback
TypeWeb Component (<mv-totem>)
Statusbeta
Also installsbutton
Keywordsexclusive, culture, environment, env-indicator, staging, production, preview-deploy, sandbox, test-mode, impersonation, devtools, deployment, build-info, safety, hostname, badge, frame, document-title, spinning-top

When to use

Avoid when

Install

node scripts/add.mjs totem --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["totem"], 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, core/position.js, components/totem/totem.js, components/totem/totem.css, components/button/button.css.

Usage

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

<div id="tt-demo" style="width:min(100%,56rem);margin-inline:auto">
  <style>
    #tt-demo { display:grid; gap:1rem; align-content:start }
    #tt-demo .tt-app { position:relative; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
    #tt-demo .tt-url { display:flex; align-items:center; gap:.5rem; height:2.25rem; padding:0 .875rem; border-bottom:1px solid var(--mv-border); background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font:.75rem/1 var(--mv-font-mono) }
    #tt-demo .tt-url svg { width:.8125rem; height:.8125rem; flex:none; color:var(--mv-fg-subtle) }
    #tt-demo .tt-url span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
    #tt-demo .tt-bar { display:flex; align-items:center; gap:1rem; height:3.5rem; padding:0 1rem 0 1.125rem; border-bottom:1px solid var(--mv-border) }
    #tt-demo .tt-brand { display:flex; align-items:center; gap:.5rem; font-weight:650; font-size:.9375rem; letter-spacing:-.01em }
    #tt-demo .tt-logo { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:var(--mv-radius-md); background:var(--mv-fg); color:var(--mv-bg) }
    #tt-demo .tt-logo svg { width:1rem; height:1rem }
    #tt-demo .tt-nav { display:flex; gap:.25rem; margin:0; padding:0; list-style:none }
    #tt-demo .tt-nav a { display:block; padding:.375rem .625rem; border-radius:var(--mv-radius-md); color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500; text-decoration:none }
    #tt-demo .tt-nav a[aria-current] { background:var(--mv-bg-muted); color:var(--mv-fg) }
    #tt-demo .tt-end { display:flex; align-items:center; gap:.75rem; margin-inline-start:auto }
    #tt-demo .tt-me { display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:var(--mv-bg-emphasis); color:var(--mv-fg); font-size:.75rem; font-weight:600 }
    #tt-demo .tt-body { display:grid; grid-template-columns:minmax(0,1fr) 14rem; gap:1rem; padding:1.125rem }
    #tt-demo .tt-card { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
    #tt-demo .tt-card h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0 0 .25rem; font-size:.875rem }
    #tt-demo .tt-card p { margin:0; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #tt-demo .tt-rows { display:grid; gap:.5rem; margin-top:.875rem }
    #tt-demo .tt-row { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); font-size:.8125rem }
    #tt-demo .tt-row span:last-child { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums }
    #tt-demo .tt-side { display:grid; gap:.75rem; align-content:start }
    #tt-demo .tt-side .mv-button { width:100% }
    #tt-demo .tt-log { min-height:2.5rem; margin:0; color:var(--mv-fg-subtle); font:.6875rem/1.5 var(--mv-font-mono); overflow-wrap:anywhere }
    #tt-demo .tt-controls { display:flex; align-items:center; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.875rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #tt-demo .tt-field { display:grid; gap:.375rem }
    #tt-demo .tt-k { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
    #tt-demo .tt-switches { display:flex; gap:.5rem 1.25rem; flex-wrap:wrap; margin-inline-start:auto }
    #tt-demo .mv-choice { font-size:.8125rem }
    #tt-demo .tt-variants { display:grid; gap:.75rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #tt-demo .tt-variants h4 { margin:0; font-size:.8125rem; font-weight:600 }
    #tt-demo .tt-variants p { margin:0; color:var(--mv-fg-muted); font-size:.75rem }
    #tt-demo .tt-chips { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap }
    @media (max-width:44rem) {
      #tt-demo .tt-nav { display:none }
      #tt-demo .tt-body { grid-template-columns:minmax(0,1fr) }
      #tt-demo .tt-switches { margin-inline-start:0 }
    }
  </style>

  <!-- Main: the same refund screen on four hosts. The totem says which world you are in. -->
  <section class="tt-app" id="tt-app" aria-label="Order console">
    <div class="tt-url">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
      <span id="tt-url">https://staging.kestrel.shop/orders/KS-48213</span>
    </div>
    <header class="tt-bar">
      <span class="tt-brand">
        <span class="tt-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="M3 7l9-4 9 4-9 4z"/><path d="M3 7v10l9 4 9-4V7"/><path d="M12 11v10"/></svg></span>
        Kestrel
      </span>
      <ul class="tt-nav">
        <li><a href="#" aria-current="page">Orders</a></li>
        <li><a href="#">Customers</a></li>
        <li><a href="#">Payouts</a></li>
      </ul>
      <span class="tt-end">
        <mv-totem id="tt-main" hostname="staging.kestrel.shop" frame="#tt-app" topple="always"
          build="2026.09.22-3" commit="8f3c2a1" api="https://api.staging.kestrel.shop/v2"
          source="Nightly snapshot, anonymized" since="2026-09-22T16:12:00-07:00">
          <a slot="actions" class="mv-button" data-variant="ghost" data-size="sm" href="#" id="tt-goprod">Open production</a>
        </mv-totem>
        <span class="tt-me" aria-hidden="true">MR</span>
      </span>
    </header>
    <div class="tt-body">
      <div class="tt-card">
        <h4>Order KS-48213 <span class="mv-badge" data-variant="success" data-shape="pill">Paid</span></h4>
        <p>Aiko Tanaka · Osaka, Japan · paid Sep 21, 2026 with Visa ending 4242</p>
        <div class="tt-rows">
          <div class="tt-row"><span>Linen overshirt, sand (M)</span><span>$148.00</span></div>
          <div class="tt-row"><span>Merino crew, charcoal (M) × 2</span><span>$236.00</span></div>
          <div class="tt-row"><span>Waxed field jacket (L)</span><span>$856.00</span></div>
        </div>
      </div>
      <div class="tt-side">
        <button type="button" class="mv-button" data-variant="destructive" id="tt-refund">Refund $1,240.00</button>
        <button type="button" class="mv-button" data-variant="outline">Resend receipt</button>
        <p class="tt-log" id="tt-log" aria-live="polite">Resolved staging from host rule staging.*</p>
      </div>
    </div>
  </section>

  <div class="tt-controls">
    <div class="tt-field">
      <span class="tt-k" id="tt-host-label">Simulated host</span>
      <mv-segmented id="tt-host" aria-labelledby="tt-host-label" value="staging">
        <button value="local">localhost</button>
        <button value="preview">Preview deploy</button>
        <button value="staging">Staging</button>
        <button value="production">Production</button>
      </mv-segmented>
    </div>
    <div class="tt-switches">
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tt-imp"> Impersonate the customer</label>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tt-frame" checked> Frame the edges</label>
      <label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tt-title"> Prefix the tab title</label>
    </div>
  </div>

  <div class="tt-variants">
    <h4>Every environment at a glance</h4>
    <div class="tt-chips">
      <mv-totem env="local" hostname="localhost:3000" build="dev server" api="http://localhost:8787"></mv-totem>
      <mv-totem env="preview" hostname="checkout-git-tax-rules.vercel.app" commit="c41d9e2"></mv-totem>
      <mv-totem env="staging" hostname="staging.kestrel.shop" build="2026.09.22-3"></mv-totem>
      <mv-totem env="sandbox" hostname="sandbox.kestrel.shop" source="Test cards only"></mv-totem>
      <mv-totem env="production" mode="Test mode" tone="warning" hostname="app.kestrel.shop" source="Test data, no real charges"></mv-totem>
      <mv-totem env="production" hostname="app.kestrel.shop" topple="always" compact="never" build="2026.09.21-1"></mv-totem>
      <mv-totem env="production" hostname="app.kestrel.shop" topple="always" build="2026.09.21-1"></mv-totem>
    </div>
    <p>Anything that is not production keeps spinning. Production topples once, then rests, tiny and quiet (last one: compact, the name slides out on hover). Click any of them for the details.</p>
  </div>

  <script type="module">
    const $ = (id) => document.getElementById(id);
    const tt = $("tt-main");
    const log = $("tt-log");
    const ago = (min) => new Date(Date.now() - min * 60_000).toISOString();

    const HOSTS = {
      local: { host: "localhost:5173", build: "dev server", commit: "working tree", api: "http://localhost:8787/v2", source: "Seeded fixtures (42 orders)", since: ago(12) },
      preview: { host: "checkout-git-tax-rules.vercel.app", build: "Preview #1184", commit: "c41d9e2", api: "https://api.staging.kestrel.shop/v2", source: "Staging replica", since: ago(95) },
      staging: { host: "staging.kestrel.shop", build: "2026.09.22-3", commit: "8f3c2a1", api: "https://api.staging.kestrel.shop/v2", source: "Nightly snapshot, anonymized", since: ago(60 * 19) },
      production: { host: "app.kestrel.shop", build: "2026.09.21-1", commit: "7ab03f5", api: "https://api.kestrel.shop/v2", source: "Primary database (us-east-1)", since: ago(60 * 42) },
    };

    const apply = (key) => {
      const h = HOSTS[key];
      $("tt-url").textContent = `https://${h.host}/orders/KS-48213`;
      tt.hostname = h.host;
      tt.build = h.build;
      tt.commit = h.commit;
      tt.api = h.api;
      tt.source = h.source;
      tt.since = h.since;
      $("tt-goprod").hidden = key === "production";
    };

    const showTitle = () => requestAnimationFrame(() => {
      if ($("tt-title").checked) log.textContent = `Tab title: ${document.title}`;
    });

    tt.addEventListener("mv-resolve", (e) => {
      const { env, source, rule, real, mode } = e.detail;
      const how = source === "attribute" ? "the env attribute" : source === "rule" ? `host rule ${rule}` : "the fallback (no rule matched)";
      log.textContent = `Resolved ${env}${mode ? ` + ${mode}` : ""} from ${how} · ${real ? "real: the top rests" : "not real: it spins"}`;
      showTitle();
    });

    $("tt-host").addEventListener("mv-change", (e) => apply(e.detail.value));
    $("tt-imp").addEventListener("change", (e) => { tt.mode = e.target.checked ? "Viewing as Aiko Tanaka" : null; });
    $("tt-frame").addEventListener("change", (e) => { tt.frame = e.target.checked ? "#tt-app" : null; });
    $("tt-title").addEventListener("change", (e) => { tt.titlePrefix = e.target.checked ? "" : null; showTitle(); });
    $("tt-goprod").addEventListener("click", (e) => {
      e.preventDefault();
      $("tt-host").value = "production";
      apply("production");
      tt.toggle(false);
    });
    $("tt-refund").addEventListener("click", () => {
      const r = tt.resolved;
      log.textContent = r.production
        ? `Refund of $1,240.00 sent to Visa ending 4242. This was real${r.mode ? `, even ${r.mode.toLowerCase()}` : ""}.`
        : `Refund simulated on ${r.label.toLowerCase()}: no money moved.`;
    });
    apply("staging");
  </script>
</div>

Cultural reference

Inception — Christopher Nolan (2010, film). Each dreamer carries a totem, a small personal object whose behavior tells them whether they are dreaming or awake: the spinning top spins forever in a dream and topples in reality. In the UI, a tiny top keeps spinning in every environment that is not real (staging, preview, test mode, impersonation) and topples to rest only in production, so a glance tells you which world your next click lands in.

API

Attributes

NameTypeDefaultDescription
envstringThe environment, as your build knows it ("staging", "preview", "local", "production", or any name). Wins over hostname rules. Known names get a label and a tone: production / prod / live (neutral), staging / uat (warning), preview / demo (accent), development / dev / local (info), sandbox / test / qa (success); other names are capitalized with the accent tone.
rulesstringOrdered hostname rules used when env is absent: "localhost|127.*=local, *.vercel.app=preview, staging.*=staging". Separate rules with commas (or ; / new lines when a pattern contains a comma), alternatives with |, and the env with =, -> or →. * matches anything (dots included); a pattern with a port (localhost:6006) is matched against host:port; /…/flags is a regular expression. Replaces the defaults (localhost and private IPs → local; .vercel.app, .netlify.app, .pages.dev, preview. → preview; staging.*, stage.*, -staging. → staging; dev.* → development; sandbox.*, qa.*, uat.*, test.*).
hostnamestringlocation.hostHost to resolve instead of the current one ("staging.acme.io", "localhost:5173" or a full URL). For previews, tests and demos.
fallbackstringproductionEnvironment used when no rule matches the host.
productioncomma-separated listproduction, prod, liveEnvironment names that count as real: only those topple and rest.
modestringA state layered on the environment that makes it not real even in production: "Test mode", "Viewing as Aiko Tanaka", "Read-only replica". Shown as the chip label, with the danger tone by default; the top spins.
labelstringVisible name of the environment (default from env: "Staging", "Preview"…).
toneneutral | accent | info | success | warning | danger | CSS colorTint override. Per environment in CSS: mv-totem[data-env="qa"] { --mv-totem-tint: … }.
descriptionstringSentence under the popover title (defaults explain whether changes reach real customers).
build / commit / api / sourcestringDetail rows of the popover: build or release, commit hash, API base URL, data source ("Nightly snapshot, anonymized"). Rows without a value are omitted.
sinceISO date-time | epoch msSince when this environment runs (deploy time, impersonation start). Shown as an en-US date with a relative time ("19 hours ago"), recomputed each time the popover opens.
localeBCP 47en-USLocale of the date and relative time.
dockbottom-start | bottom-end | top-start | top-endFixes the totem in a corner of the viewport (or of the nearest ancestor that contains fixed elements), with a raised shadow. Without it the totem is inline (a header, a toolbar).
placementtop | bottom (-start | -end)bottom-end, or away from the docked edgePreferred side of the popover (flips and shifts to stay in the viewport).
frame"" | "viewport" | CSS selectorOutside production, draws a tinted border with a small labeled tab around the viewport (empty value, top layer) or around the matched element (closest ancestor first, made position: relative if it was static, restored afterwards). Never shown in production.
title-prefix"" | template"[{env}] "Outside production, prefixes document.title ("[staging] Orders · Kestrel") and puts the prefix back when a router rewrites the title. Template placeholders: {env}, {label}, {mode}. Removed in production and when the element goes away. One instance owns the title at a time.
compactauto | always | neverautoGlyph-only chip whose name slides out on hover and keyboard focus. auto = compact in production only.
topplesession | always | neversessionWhen the fall is played on load in production: once per tab session (then it is already resting), on every load, or never. A live switch to production always plays it; reduced motion never does.
openbooleanOpens the popover (reflected).
data-env / data-tone / data-real / data-mode / data-glyph / data-compactset by the componentStyling hooks: normalized env, tone, real (production without mode), mode present, glyph state (spin | topple | check | rest), compact.

Properties

NameTypeDescription
resolved{ env, label, tone, real, production, mode, name, source, rule, host }Current resolution (read-only). source: "attribute" | "rule" | "fallback"; rule: the pattern that matched; production: the env is a production name (true even with a mode on); name: accessible name ("Staging environment").
realbooleanTrue in production with no mode on (read-only).
rulesstring | Array<[pattern, env]> | Array<{ match, env }> | Record<pattern, env>Hostname rules as data; patterns may be strings (globs, |), RegExp or functions (host, hostWithPort) => boolean. A string is reflected to the attribute.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (name, nameMode, realText, unrealText, modeText, rowEnv, rowMode, rowHost, rowBuild, rowCommit, rowApi, rowSource, rowSince, rowFrom, fromAttribute, fromRule, fromFallback, copy, copied, copyFailed, announce, announceMode, announceCopied, titlePrefix). English defaults.
env / hostname / mode / build / commit / api / source / since / frame / titlePrefix / …reflectedMirror the attributes; any change re-resolves live.

Methods

NameDescription
toggle(force?)Opens or closes the popover.
check()Flicks the top again: in production it spins up, wobbles and topples back to rest (nothing under reduced motion or outside production).
MvTotem.resolve(hostname, { rules?, fallback? })Static: resolves a hostname with the same rules, without an element (tests, server rendering). Returns { env, source, rule, host }.

Events

NameDescription
mv-resolveThe environment was resolved (on connection and on every change). detail: the resolved object.
mv-toggleThe popover opens (cancelable: preventDefault() keeps it closed) or closes. detail: { open }.
mv-copyCopy details was pressed (cancelable: preventDefault() and send detail.text to your own bug reporter instead of the clipboard). detail: { text }.

Content structure

NameDescription
actionsChildren with slot="actions" (a link to production, Stop impersonating…) go in the popover footer, next to Copy details.
detailsChildren with slot="details" go under the detail rows (a note, a link to the release notes). Children added later are adopted too.

CSS classes

NameDescription
mv-totem-chipThe trigger <button>: .mv-totem-glyph + .mv-totem-label (.mv-totem-label-text).
mv-totem-glyphThe top (aria-hidden): -floor, -top (-stem, -body with -facets). --_g sets its size.
mv-totem-panelThe popover (Popover API top layer): -head, -emblem, -title, -text, -details (.mv-totem-row, dt/dd, dd[data-mono]), -slot, -foot, -actions, -copy.
mv-totem-frameThe page-edge frame (aria-hidden, pointer-events: none) and its -frame-tab. data-scope="viewport | element".

CSS variables

NameDefaultDescription
--mv-totem-tinttone colorEnvironment tint (chip, top, frame, popover stripe).
--mv-totem-spin0.7sDuration of one facet cycle of the spinning top (the precession is 3× slower).
--mv-totem-frame-width3pxFrame thickness.
--mv-totem-frame-inknear-blackText color of the frame tab on the tint.
--mv-totem-inset12pxDistance from the corner when docked.
--mv-totem-shine / --mv-totem-shade / --mv-totem-floorwhite 55% / black 32% / fg 30%Artistic colors of the top: facet highlights, round shading, contact shadow.

Accessibility

The chip is a real <button> (aria-haspopup="dialog", aria-expanded, aria-controls) whose accessible name always spells out the environment (“Staging environment”, “Viewing as Aiko Tanaka, Production environment”) and starts with the visible text, so voice control works; the top is aria-hidden, the color is never the only signal (the visible name and the upright or lying pose say it too). Enter or Space opens a labelled, described non-modal dialog placed right after the chip in the DOM, focus moves to it, Tab reaches Copy details and the slotted actions, Escape closes it and returns focus to the chip, an outside click or focus leaving the component closes it without moving focus. The details are a real description list. A live change of environment or mode after load is announced once in a polite live region (“Now in Production environment.”), and Copy details announces its result. The title prefix also reaches screen reader users, who hear “[staging]” in the tab name. Compact chips keep the full accessible name and reveal the visible name on hover and keyboard focus. Reduced motion (OS setting or data-motion="reduce"): nothing spins or falls; outside production the top stands upright and tinted, in production it lies on its side, so the two states stay unmistakable without motion. Forced colors: the top uses Highlight (not real) or CanvasText (real), the frame Highlight, the chip ButtonText. The frame is aria-hidden with pointer-events: none and never intercepts clicks.