エクスクルーシブ
Tremor <mv-tremor>
近づきつつある上限を、さりげなく早めに知らせる警告です。バナーやモーダルでは大げさすぎる場面向けで、ストレージやクォータの残りわずか、上限に迫る予算、尽きかけているプリペイドのクレジット、増え続けるキュー、期限が近づく証明書、スプリント、締め切りなどに使えます。
文化的リファレンス
ジュラシック・パーク、スティーヴン・スピルバーグ(マイケル・クライトンの小説が原作)(1993年、映画)。遠くで足音が響くたびに大きくなるコップの水の同心円状の波紋が、姿が見えるずっと前から巨大な何かが近づいていることを告げます。UI では、上限がまだ遠いうちは小さな液面が静止したままで、クォータ、予算、締め切りが近づくにつれて波紋がより頻繁かつ強くなり、上限に達すると揺れます。
仕組み
近づきつつある上限を知らせる、さりげない早期警告。バナーやモーダルでは大げさすぎる場面向けです:ストレージやクォータの逼迫、上限に近づく予算、残り少ないプリペイドクレジット、増え続けるキュー、迫る証明書の期限、スプリントや締め切り。上から見た小さな液体の表面(canvas、DPR ≤ 2)は、上限が遠いうちは完全に静止しています。最初の warn しきい値を超えると、遠くの足音のように波紋が立ち始めます。最初はまれでかすかな輪ですが、しきい値を超えるごとに頻繁かつ強くなり、上限では表面全体が揺れます。上限に向けた実際の一歩(新しい測定値)は、すぐに波紋として現れます。周囲の細いゲージは接近の度合いを示し、しきい値ごとに刻みがあります。その横には、どれだけ近く、どれだけ早く到達するかを平易な文で示します(「82% of storage used · full in ~9 days at current rate」)。レートは値の履歴に対する最小二乗フィット(history で初期化し、record() または値の変更のたびに供給)か、宣言したレートです。eta-warn を使うと、値がまだ低くても接近が速い場合にレベルを上げられます。上向き(上限に向かう値)、下向き(ベースラインが上限より上:クレジット、残高、在庫)、時間モード(at、任意の from、しきい値は残り時間で指定)で動作します。dot バリアント(16-24 px、文中にインライン)と glass バリアント(レベルチップ、大きな測定値、予測、レート、予測日、次のステップ、actions スロットを備えたカード)があります。レベルは点灯した輪の数と言葉でも伝えられ、色だけに頼ることはありません。
| カテゴリー | フィードバック |
|---|---|
| タイプ | Web Component(<mv-tremor>) |
| ステータス | 安定版 |
| キット | アラート疲れのないモニタリング |
| Keywords | exclusive, culture, quota, usage, limit, storage, budget, forecast, eta, early-warning, ambient, meter, threshold, deadline, countdown, credits, queue, canvas, ripple, calm-technology |
When to use
- A storage, seat or API quota should warn well before it is full without interrupting anyone with a banner
- A monthly budget or prepaid balance should show how soon it will hit its cap at the current spending rate
- A dashboard lists many limits and only the ones actually getting close should draw the eye
- A certificate, contract or sprint deadline is still far off but its approach should be felt as it nears
Avoid when
- A session is about to expire and someone must actively confirm to keep it alive → use Hatch instead
- The limit is a refilling rate limit or cooldown that gates an action and comes back over time → use Recharge instead
- The status is an incident severity that should decay after trouble, not an approach toward a limit → use Alert Phase instead
インストール
node scripts/add.mjs tremor --out ./src/marvelousMarvelous UI MCP サーバーを使う AI エージェント:install_components({ slugs: ["tremor"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
コピーされるファイル(依存関係を含む):tokens/tokens.css、core/base.css、core/canvas.js、core/dom.js、core/element.js、core/motion.js、components/tremor/tremor.js、components/tremor/tremor.css。
使い方
クイックスタート、動作する最小限のマークアップ:
<mv-tremor variant="glass" label="Storage" value="41.2" limit="50" unit="GB"></mv-tremor>基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:
<div id="tr-demo" style="width:min(100%,58rem);margin-inline:auto">
<style>
#tr-demo { display:grid; gap:1rem; align-content:start }
#tr-demo .tr-panel { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#tr-demo .tr-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#tr-demo .tr-head h4 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
#tr-demo .tr-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
#tr-demo .tr-main { padding:1rem 1.125rem }
#tr-demo .tr-main mv-tremor { box-shadow:none }
#tr-demo .tr-controls { display:flex; align-items:center; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.75rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle); border-radius:0 0 var(--mv-radius-xl) var(--mv-radius-xl) }
#tr-demo .tr-scrub { display:grid; grid-template-columns:auto minmax(8rem,1fr) 4.5rem; align-items:center; gap:.75rem; flex:1 1 20rem; min-width:0; font-size:.8125rem }
#tr-demo .tr-scrub output { color:var(--mv-fg-muted); font-variant-numeric:tabular-nums; text-align:end }
#tr-demo .tr-actions { display:flex; gap:.5rem; flex-wrap:wrap }
#tr-demo .tr-log { flex:1 0 100%; min-height:1.1rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#tr-demo .tr-pair { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr)); gap:1rem }
#tr-demo .tr-list { margin:0; padding:.375rem 0; list-style:none }
#tr-demo .tr-row { display:grid; grid-template-columns:8.5rem minmax(0,1fr); align-items:center; gap:1rem; padding:.5625rem 1.125rem }
#tr-demo .tr-row + .tr-row { border-top:1px solid var(--mv-border) }
#tr-demo .tr-row > span { color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#tr-demo .tr-row mv-tremor { max-width:100% }
#tr-demo .mv-choice { font-size:.8125rem }
@media (max-width:36rem) { #tr-demo .tr-row { grid-template-columns:minmax(0,1fr); gap:.25rem } }
</style>
<!-- Main: workspace storage, 14 days of history, forecast from the trend -->
<section class="tr-panel" aria-labelledby="tr-title">
<div class="tr-head">
<div>
<h4 id="tr-title">Tidewell Studio · Workspace</h4>
<p>Team plan · 50 GB of shared storage · 12 members</p>
</div>
</div>
<div class="tr-main">
<mv-tremor id="tr-storage" variant="glass" label="Storage" value="41.2" limit="50" unit="GB" warn="75%, 90%, 97%" style="border:0;padding:0">
<button slot="actions" type="button" class="mv-button" data-size="sm">Upgrade to 200 GB</button>
<button slot="actions" type="button" class="mv-button" data-variant="ghost" data-size="sm">Review large files</button>
</mv-tremor>
</div>
<div class="tr-controls">
<label class="tr-scrub">
<span>Storage used</span>
<input type="range" class="mv-slider" id="tr-scrub" min="20" max="52" step="0.1" value="41.2">
<output id="tr-out" for="tr-scrub">41.2 GB</output>
</label>
<span class="tr-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="tr-up">Upload 2 GB</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="tr-free">Free 5 GB</button>
</span>
<p class="tr-log" id="tr-log" aria-live="off">At 82%, past the first ring. Rings start at 75%, quicken at 90% and 97%, and the surface shakes at 100%.</p>
</div>
</section>
<div class="tr-pair">
<!-- A budget approaching its cap, forecast from month-to-date spend -->
<mv-tremor id="tr-spend" variant="glass" label="Cloud spend · September" value="4610" limit="5000" currency="USD" limit-label="at cap" warn="75%, 90%, 97%"></mv-tremor>
<!-- A deadline in the distance: certificate renewal -->
<mv-tremor id="tr-cert" variant="glass" label="TLS certificate" limit-label="expires" warn="30d, 7d, 1d"></mv-tremor>
</div>
<!-- Dot variant: inline readings in an overview list -->
<section class="tr-panel" aria-labelledby="tr-overview">
<div class="tr-head">
<div>
<h4 id="tr-overview">Limits overview</h4>
<p>Only what is getting close moves; everything far from its limit stays still.</p>
</div>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="tr-text" checked> Show text</label>
</div>
<ul class="tr-list">
<li class="tr-row"><span>CI · build queue</span><mv-tremor id="tr-queue" label="Build queue" value="38" limit="50" unit="jobs" warn="60%, 80%, 95%"></mv-tremor></li>
<li class="tr-row"><span>Messaging</span><mv-tremor id="tr-sms" label="SMS credits" value="2600" baseline="20000" limit="0"></mv-tremor></li>
<li class="tr-row"><span>Team</span><mv-tremor label="Seats" value="12" limit="12" unit="seats"></mv-tremor></li>
<li class="tr-row"><span>Public API</span><mv-tremor label="API requests today" value="18400" limit="100000"></mv-tremor></li>
<li class="tr-row"><span>Planning</span><mv-tremor id="tr-sprint" label="Sprint 42" limit-label="ends" warn="3d, 1d, 4h"></mv-tremor></li>
</ul>
</section>
<script type="module">
const $ = (id) => document.getElementById(id);
const DAY = 864e5;
const now = Date.now();
// Readings every `step` ms from `start` to `end`, with a little deterministic noise.
const series = (count, step, start, end, noise = 0) => Array.from({ length: count }, (_, i) => {
const k = i / (count - 1);
const wobble = i === count - 1 ? 0 : Math.sin(i * 2.3) * noise;
return { t: now - (count - 1 - i) * step, value: Math.round((start + (end - start) * k + wobble) * 10) / 10 };
});
const storage = $("tr-storage");
storage.history = series(15, DAY, 28, 41.2, 0.4);
$("tr-spend").history = series(23, DAY, 180, 4610, 60);
$("tr-queue").history = series(16, 2 * 6e4, 17, 38, 1);
$("tr-sms").history = series(11, DAY, 11000, 2600, 150);
const cert = $("tr-cert");
cert.from = String(now - 85 * DAY);
cert.at = String(now + 5 * DAY + 3 * 36e5);
$("tr-sprint").at = String(now + 2 * DAY + 6 * 36e5);
const scrub = $("tr-scrub");
const out = $("tr-out");
const log = $("tr-log");
const show = (v) => { scrub.value = v; out.textContent = `${Number(v).toFixed(1)} GB`; };
const setStorage = (v) => { v = Math.max(0, Math.round(v * 10) / 10); storage.value = v; show(v); };
scrub.addEventListener("input", () => setStorage(Number(scrub.value)));
$("tr-up").addEventListener("click", () => setStorage(Number(storage.value) + 2));
$("tr-free").addEventListener("click", () => setStorage(Number(storage.value) - 5));
// The opening line follows the initial reading, so it never contradicts the badge.
const pct = Math.round((Number(storage.getAttribute("value")) / Number(storage.getAttribute("limit"))) * 100);
const where = pct < 75 ? "still below the first ring" : pct < 100 ? "past the first ring" : "at the limit";
log.textContent = `At ${pct}%, ${where}. Rings start at 75%, quicken at 90% and 97%, and the surface shakes at 100%.`;
const time = () => new Date().toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", second: "2-digit" });
$("tr-demo").addEventListener("mv-tremor-level", (e) => {
const { name, rising } = e.detail;
const label = e.target.getAttribute("label");
log.textContent = `${time()} · mv-tremor-level · ${label}: ${name} (${rising ? "rising" : "easing"})`;
});
$("tr-text").addEventListener("change", (e) => {
for (const el of document.querySelectorAll("#tr-demo .tr-list mv-tremor")) {
if (e.target.checked) el.removeAttribute("text"); else el.setAttribute("text", "none");
}
});
</script>
</div>API
Attributes
| Name | タイプ | Default | Description |
|---|---|---|---|
value | number | baseline | Current reading. Every change is recorded with its time in the history (readings less than a second apart are merged, so a dragged slider counts once) and, when it moves toward the limit past the first threshold, lands as an immediate ripple. |
limit | number | 100 | The cap the value approaches. Below baseline, the approach is downward (credits, balance, stock running out). |
baseline | number | 0 | Where the approach starts. Proximity = (value − baseline) / (limit − baseline). |
warn | list ("75%, 90%, 97%" | "0.8, 0.95" | "40, 45" | "7d, 2d, 12h") | 75%, 90%, 97% (time mode: 7d, 2d, 12h) | Up to three thresholds for the distant, near and close levels (with fewer, the lowest levels are skipped). Percentages and fractions are of the way from baseline to limit, bare numbers above 1 are absolute values; in time mode, durations left (or percentages of the from → at span). The limit itself is the reached level. |
eta-warn | durations ("30d, 7d, 1d") | Forecast thresholds: when the estimated time to the limit drops under them, the level rises accordingly (never to reached), so a fast approach is felt before the value is high. | |
at | ISO date | epoch ms | epoch s | "+3d" | Time mode: the deadline. value, limit and the rate are then ignored; the level follows the time left. | |
from | ISO date | epoch ms | epoch s | "-30d" | Time mode: when the period started. Gives a real proximity (elapsed share) for the gauge and the meter, and allows percentage thresholds. | |
rate | rate ("1.2/day", "300 per hour", "5/min") | Declared rate of change in value units, used instead of the history fit (e.g. your server already knows it). | |
window | duration ("14d", "2h") | Only readings this recent are used for the rate fit. Default: the whole history (last 240 readings). | |
unit | string | Unit shown after amounts (“GB”, “jobs”, “seats”). | |
currency | ISO 4217 code | Formats amounts as money (“$4,610”); unit is then ignored. | |
locale | BCP 47 tag | en-US | Locale of numbers, percentages, durations and dates. |
label | string | Usage (time mode: Deadline) | What is measured. Title of the glass card, accessible name of the meter, and part of the sentence (“of storage used”, “Sprint 42 ends in 2 days”). |
limit-label | string | full | empty (downward) | due (time mode) | Word for reaching the limit, used in the forecast and the time sentence: “full in ~9 days”, “at cap in ~2 days”, “expires in 5 days”. |
variant | dot | glass | dot | dot: a 16-24 px liquid dot with the sentence beside it, for lists, tables and headers. glass: a card with the liquid, the level chip, the big reading, the forecast, a details row (rate, forecast date, next step) and the actions slot. |
size | sm | lg | (md) | Dot size: 16, 20 or 24 px (--mv-tremor-size overrides it, for both variants). |
text | なし | Dot only: hides the sentence (the dot alone). The sentence stays the meter’s accessible text. | |
quiet | boolean | No live announcement when the level rises (the event is still emitted). | |
data-level / data-mode / data-variant | calm | distant | near | close | reached / quantity | time / dot | glass | Set by the component (styleable). --mv-tremor-proximity (0..1) is set on the host. |
Properties
| Name | タイプ | Description |
|---|---|---|
level | 0 | 1 | 2 | 3 | 4 | Current level: calm, distant, near, close, reached (read-only). |
levelName | "calm" | "distant" | "near" | "close" | "reached" | Current level name (read-only). |
proximity | number | null | Share of the way from baseline (or from) to the limit (or at); above 1 when over the limit; null in time mode without from (read-only). |
estimate | { rate, eta, at } | rate in value units per ms (signed) or null, eta = ms until the limit at that rate (null when not approaching), at = the forecast Date or null (read-only, recomputed on read). |
history | Array<{ t, value }> (also [t, value] pairs; t as Date, ISO string or epoch ms) | Readings used for the rate, oldest first. Assign server history to get a forecast immediately; if value is not set, the latest reading becomes the value. |
strings | Partial<Record<string, string>> | Overrides for every text (keys: usage, deadline, full, empty, due, used, left, usedBare, leftBare (without a label), forecast, steady, atLimit, overLimit, timeLeft, timeNow, timeOver, noteUsed, noteLeft, noteTime, noteOver, detailRate, detailEta, detailNext, detailElapsed, nextAt, nextAtIn, nextLimit, markLeft, markTimeLeft, levels (5 names separated by |), levelReachedTime, announce, per ({ day: "day", … })). Placeholders: {percent}, {label}, {amount}, {limitLabel}, {LimitLabel}, {eta}, {remaining}, {ago}, {value}, {limit}, {start}, {date}, {mark}, {time}, {level}, {summary}. English defaults. |
value / limit / baseline / warn / etaWarn / at / from / rate / window / unit / currency / locale / label / limitLabel / variant / size / text / quiet | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
record(value, time = Date.now()) | Adds a reading (e.g. from a poll or a websocket). If it is the latest one, it also becomes the value, with its ripple; older readings only refine the rate. |
clearHistory() | Forgets the readings (keeps the current value as the only one): the rate and forecast disappear until new readings arrive. |
Events
| Name | Description |
|---|---|
mv-tremor-level | The level changed. detail: { level, previous, name, rising, proximity, eta, summary }. Cancelable: preventDefault() skips the built-in polite announcement (e.g. your app announces it its own way). Not emitted for the initial level: read level after connection. |
Content structure
| Name | Description |
|---|---|
actions | Children with slot="actions" (buttons, links: “Upgrade”, “Review large files”) are shown under the glass card’s details. |
CSS classes
| Name | Description |
|---|---|
mv-tremor-disc | The liquid and its gauge (canvas inside). Carries role="meter" (or role="img" in time mode without from) with the full reading as its text. |
mv-tremor-summary | Dot: the sentence beside the liquid (aria-hidden, duplicated in the meter text). |
mv-tremor-body | Glass: -head (-label, -level chip with -glyph rings and -level-text), -reading (-figure, -note), -forecast, -details (dl of -detail), -actions. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-tremor-liquid | color-mix(in oklab, var(--mv-info) 50%, var(--mv-fg-muted)) | Calm tone of the liquid and its rings. |
--mv-tremor-warn | var(--mv-warning) | Tone the liquid moves toward at the distant, near and close levels. |
--mv-tremor-reached | var(--mv-danger) | Tone at the limit (mixed in at the close level). |
--mv-tremor-track | color-mix(in oklab, var(--mv-fg) 9%, transparent) | Gauge track around the liquid. |
--mv-tremor-surface | var(--mv-surface) | Card and liquid base color (the threshold notches are cut in it). |
--mv-tremor-size | 1.25rem (dot) / 6.5rem (glass) | Diameter of the liquid and its gauge. |
--mv-tremor-proximity | 0..1 | Set by the component on the host: how far along the approach is, for your own styling. |
Accessibility
The liquid is a role="meter" (0-100, the share of the approach) named by the label and whose aria-valuetext is the whole reading in words (“82% of storage used, full in about 9 days at current rate. Distant.”); in time mode without a start it becomes a role="img" with the same text. The visible sentence, figure and chip are aria-hidden because the meter already says them, while the details list (rate, forecast date, next step) stays readable. When the level rises, a polite live region says it once (“Storage: Approaching. 91% of storage used, full in about 4 days at current rate.”); nothing is announced when it falls, on first render, with quiet, or when mv-tremor-level is cancelled. The component is not focusable and takes no input; slotted actions keep their own semantics and focus. Nothing depends on color: the level is also given by words (chip and text), by the number of lit rings in the chip glyph, and by the gauge length; in forced-colors mode the chip, card and canvas use system colors (CanvasText gauge on a GrayText track, Highlight at the limit). Motion is ambient and slow (a ring every 7 s at the first level, under a second only at the limit) and never flashes. With reduced motion (OS setting or data-motion="reduce") nothing moves: the liquid shows 1 to 4 still concentric rings for the level instead of ripples and shaking. The animation runs in a frameLoop that sleeps while the level is calm, when the element is offscreen or the tab hidden, and stops when it is removed; colors are re-read on theme and forced-colors changes. Known limits: the forecast is a straight line through the recent history (window), so a sudden spike or a weekly cycle moves it a lot; without a rate or at least two samples there is no forecast, only the share of the limit.