独家
Tremor <mv-tremor>
针对逐渐逼近的限制的环境式预警,适用于横幅或弹窗显得过重的场景:存储或配额即将用满、预算接近上限、预付额度即将耗尽、队列不断增长、证书、迭代或截止日期临近。
文化出处
《侏罗纪公园》,史蒂文·斯皮尔伯格(改编自迈克尔·克莱顿的小说)(1993 年,电影)。一杯水中的同心涟漪随着远处每一次脚步声而扩大,早在看见之前就预示着某个庞然大物正在逼近。在界面中,当限额还很远时,一小片液面保持静止;随着配额、预算或截止日期临近,涟漪越来越频繁、越来越强烈,到达时则剧烈震动。
工作原理
针对逐渐逼近的限额的环境式预警,适用于横幅或模态框显得过重的场景:存储或配额即将用满、预算接近上限、预付额度即将用完、队列不断增长,或证书、冲刺、截止日期临近。一个从上方俯视的小液面(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/marvelous使用 Marvelous 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.