익스클루시브

Tremor <mv-tremor>

배너나 모달로는 과한 경우를 위해, 다가오는 한도를 은은하게 미리 경고합니다. 차오르는 저장 공간이나 할당량, 상한에 가까워지는 예산, 바닥나는 선불 크레딧, 늘어나는 대기열, 다가오는 인증서 만료, 스프린트 종료, 마감일에 적합합니다.

문화적 레퍼런스

쥬라기 공원, 스티븐 스필버그 (마이클 크라이튼의 소설 원작) (1993, 영화). 멀리서 발소리가 울릴 때마다 커지는 물컵 속 동심원 물결은, 거대한 무언가가 모습을 드러내기 훨씬 전부터 다가오고 있음을 알립니다. UI에서는 작은 액체 표면이 한도가 멀리 있는 동안 잔잔하게 머물다가, 할당량, 예산 또는 마감이 가까워질수록 더 자주, 더 강하게 출렁이고, 한도에 도달하면 흔들립니다.

작동 방식

배너나 모달은 과한 상황을 위한, 가까워지는 한계에 대한 은은한 조기 경고입니다: 저장 공간이나 할당량이 차는 경우, 예산이 상한에 가까워지는 경우, 선불 크레딧이 소진되는 경우, 대기열이 늘어나는 경우, 인증서, 스프린트 또는 마감일이 다가오는 경우. 위에서 내려다본 작은 액체 표면(canvas, DPR ≤ 2)은 한계가 멀 때는 완전히 잔잔합니다. 첫 번째 warn 임계값을 넘으면 멀리서 들리는 발소리처럼 물결이 일기 시작해, 처음에는 드물고 희미한 고리가, 이후 임계값마다 더 잦고 강한 고리가 생기며, 한계에 이르면 표면 전체가 흔들립니다. 한계를 향한 실제 진전(새 측정값)은 모두 즉시 물결로 나타납니다. 주위의 얇은 게이지는 접근이 얼마나 진행됐는지 임계값마다 눈금과 함께 보여 줍니다. 그 옆에는 얼마나 가깝고 얼마나 빨리 다가오는지 평이한 문장으로 알려 줍니다(“82% of storage used · full in ~9 days at current rate”). 속도는 값 이력의 최소제곱 적합(history로 초기값을 주고, record() 또는 값이 바뀔 때마다 갱신)이거나 선언된 rate입니다. eta-warn은 값이 아직 낮더라도 접근이 빠르면 수준을 높일 수 있습니다. 상향(값이 상한을 향함), 하향(기준선이 한계보다 위: 크레딧, 잔액, 재고), 시간 모드(at, 선택적 from, 남은 기간으로 나타낸 임계값)로 동작합니다. 점 변형(16-24 px, 문장 안 인라인)과 glass 변형(수준 칩, 큰 측정값, 예측, 속도, 예상 날짜, 다음 단계, 작업 슬롯이 있는 카드). 수준은 켜진 고리의 개수와 문장으로도 전달되며, 결코 색만으로 전달되지 않습니다.

카테고리피드백
유형Web Component (<mv-tremor>)
상태안정
키트알림 피로 없는 모니터링
Keywordsexclusive, 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유형DefaultDescription
valuenumberbaselineCurrent 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.
limitnumber100The cap the value approaches. Below baseline, the approach is downward (credits, balance, stock running out).
baselinenumber0Where the approach starts. Proximity = (value − baseline) / (limit − baseline).
warnlist ("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-warndurations ("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.
atISO date | epoch ms | epoch s | "+3d"Time mode: the deadline. value, limit and the rate are then ignored; the level follows the time left.
fromISO 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.
raterate ("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).
windowduration ("14d", "2h")Only readings this recent are used for the rate fit. Default: the whole history (last 240 readings).
unitstringUnit shown after amounts (“GB”, “jobs”, “seats”).
currencyISO 4217 codeFormats amounts as money (“$4,610”); unit is then ignored.
localeBCP 47 tagen-USLocale of numbers, percentages, durations and dates.
labelstringUsage (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-labelstringfull | 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”.
variantdot | glassdotdot: 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.
sizesm | 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.
quietbooleanNo live announcement when the level rises (the event is still emitted).
data-level / data-mode / data-variantcalm | distant | near | close | reached / quantity | time / dot | glassSet by the component (styleable). --mv-tremor-proximity (0..1) is set on the host.

Properties

Name유형Description
level0 | 1 | 2 | 3 | 4Current level: calm, distant, near, close, reached (read-only).
levelName"calm" | "distant" | "near" | "close" | "reached"Current level name (read-only).
proximitynumber | nullShare 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).
historyArray<{ 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.
stringsPartial<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 / quietreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-tremor-levelThe 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

NameDescription
actionsChildren with slot="actions" (buttons, links: “Upgrade”, “Review large files”) are shown under the glass card’s details.

CSS classes

NameDescription
mv-tremor-discThe 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-summaryDot: the sentence beside the liquid (aria-hidden, duplicated in the meter text).
mv-tremor-bodyGlass: -head (-label, -level chip with -glyph rings and -level-text), -reading (-figure, -note), -forecast, -details (dl of -detail), -actions.

CSS variables

NameDefaultDescription
--mv-tremor-liquidcolor-mix(in oklab, var(--mv-info) 50%, var(--mv-fg-muted))Calm tone of the liquid and its rings.
--mv-tremor-warnvar(--mv-warning)Tone the liquid moves toward at the distant, near and close levels.
--mv-tremor-reachedvar(--mv-danger)Tone at the limit (mixed in at the close level).
--mv-tremor-trackcolor-mix(in oklab, var(--mv-fg) 9%, transparent)Gauge track around the liquid.
--mv-tremor-surfacevar(--mv-surface)Card and liquid base color (the threshold notches are cut in it).
--mv-tremor-size1.25rem (dot) / 6.5rem (glass)Diameter of the liquid and its gauge.
--mv-tremor-proximity0..1Set 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.

이 페이지는 AI로 번역되었습니다. 번역 문제 신고