独家
Still Time <mv-still-time>
环境动效调速器:包裹任意区域,其中的所有动画(CSS 关键帧、CSS 过渡、Web Animations、静音视频)只以用户操作的速度运行。
文化出处
《SUPERHOT》,SUPERHOT Team(2016 年,游戏)。只有你动时,时间才会流动:你静止不动时,整个世界都会冻结。在界面中,环境动画的速度只与用户的操作速度相当,用户静止时则慢到几乎冻结。
工作原理
环境动效调节器:包裹任意区域,其中的每个动画(CSS 关键帧、CSS 过渡、Web Animations、静音视频)的运行速度都只跟随用户的操作强度。指针速度、滚轮和滚动速度、按键和触摸移动共同决定一个活跃度,再由一个临界阻尼弹簧把它转换为平滑的时间速率:静止时几乎冻结(默认 ×0.03,或用 idle-freeze 完全停止),手在操作时恢复全速(×1)。速率通过 playbackRate 应用,不会重启任何动画,并以 --mv-time-rate、rate 属性、一个按比例缩放的 time 时钟和一个节流的 mv-timerate 事件对外暴露,方便 canvas 和着色器循环跟随;调节器在静止时会完全休眠。默认平静,一上手就鲜活起来。
| 分类 | 背景 |
|---|---|
| 类型 | Web Component(<mv-still-time>) |
| 状态 | 稳定版 |
| Keywords | exclusive, culture, ambient, motion, playback-rate, time-scale, slow-motion, activity, idle, calm, governor, web-animations, css-animations, background, hero, attention |
When to use
- A landing hero or dashboard has decorative motion that competes with reading and should calm down while visitors read
- Ambient loops (marquees, orbits, floating cards, shimmer) should feel responsive without distracting idle users
- A kiosk or wall display should stay nearly still until someone interacts with it
- Canvas or shader backgrounds need a shared speed signal tied to user activity
Avoid when
- An animation shows real progress or status (spinner, countdown) that must never slow down: mark it data-still-time-ignore
- The goal is to cap the total amount of motion on a page for performance or accessibility → use Motion Budget instead
- Motion should simply follow scroll position rather than activity → use Scroll Velocity instead
安装
node scripts/add.mjs still-time --out ./src/marvelous使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["still-time"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(含依赖):tokens/tokens.css、core/base.css、core/dom.js、core/element.js、core/motion.js、components/still-time/still-time.js、components/still-time/still-time.css。
用法
标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:
<div id="st-demo">
<style>
#st-demo { display:grid; gap:1rem; width:100%; max-width:68rem; margin-inline:auto; font-size:.875rem }
#st-demo .st-hero { position:relative; overflow:hidden; display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); align-items:center; gap:2rem; min-height:27rem; padding:2.5rem 2.5rem 5.5rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-2xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); isolation:isolate }
#st-demo .st-sky { position:absolute; inset:0; z-index:-1; pointer-events:none; overflow:hidden }
#st-demo .st-blob { position:absolute; width:26rem; height:26rem; border-radius:50%; opacity:.55; background:radial-gradient(closest-side, color-mix(in oklch, var(--_c) 55%, transparent), transparent 70%); animation:st-drift 18s ease-in-out infinite alternate }
#st-demo .st-b1 { --_c:var(--mv-accent); left:-6rem; top:-8rem }
#st-demo .st-b2 { --_c:var(--mv-info); right:-4rem; top:-6rem; animation-duration:22s; animation-delay:-7s }
#st-demo .st-b3 { --_c:var(--mv-success); right:18%; bottom:-14rem; animation-duration:26s; animation-delay:-13s; opacity:.35 }
@keyframes st-drift { to { transform:translate(4rem, 3rem) scale(1.15) } }
#st-demo .st-copy { display:grid; gap:1rem; justify-items:start; max-width:28rem }
#st-demo .st-copy h2 { margin:0; font-size:clamp(1.75rem, 1.2rem + 1.8vw, 2.5rem); line-height:1.08; letter-spacing:-.03em; font-weight:650 }
#st-demo .st-copy p { margin:0; color:var(--mv-fg-muted); font-size:.9375rem; line-height:1.55 }
#st-demo .st-cta { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:.25rem }
#st-demo .st-visual { position:relative; height:21rem }
#st-demo .st-orbit { position:absolute; left:50%; top:50%; width:15rem; height:15rem; margin:-7.5rem 0 0 -7.5rem }
#st-demo .st-ring { position:absolute; inset:0; border-radius:50%; border:1px dashed color-mix(in oklch, var(--mv-fg) 16%, transparent) }
#st-demo .st-ring-2 { inset:2.5rem; border-style:solid; border-color:color-mix(in oklch, var(--mv-accent) 22%, transparent) }
#st-demo .st-spin { position:absolute; inset:0; animation:st-rotate 16s linear infinite }
#st-demo .st-spin-2 { inset:2.5rem; animation-duration:9s; animation-direction:reverse }
#st-demo .st-dot { position:absolute; left:50%; top:0; width:.75rem; height:.75rem; margin:-.375rem 0 0 -.375rem; border-radius:50%; background:var(--mv-accent); box-shadow:0 0 0 4px color-mix(in oklch, var(--mv-accent) 18%, transparent); transform-origin:50% 7.875rem }
#st-demo .st-spin-2 .st-dot { width:.5rem; height:.5rem; margin:-.25rem 0 0 -.25rem; transform-origin:50% 5.25rem; background:var(--mv-fg-muted); box-shadow:none }
#st-demo .st-mark { position:absolute; left:50%; top:50%; display:grid; place-items:center; width:4rem; height:4rem; margin:-2rem 0 0 -2rem; border-radius:1.25rem; color:var(--mv-fg-on-accent); background:linear-gradient(145deg, color-mix(in oklch, var(--mv-accent), white 18%), color-mix(in oklch, var(--mv-accent), black 18%)); box-shadow:0 12px 30px -10px color-mix(in oklch, var(--mv-accent) 70%, transparent); animation:st-breathe 4s ease-in-out infinite }
#st-demo .st-mark svg { width:1.75rem; height:1.75rem }
@keyframes st-rotate { to { rotate:360deg } }
@keyframes st-breathe { 50% { scale:1.06 } }
#st-demo .st-card { position:absolute; display:grid; gap:.375rem; margin:0; padding:.75rem .875rem; min-width:11.5rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:color-mix(in oklch, var(--mv-surface-raised) 88%, transparent); -webkit-backdrop-filter:blur(10px); backdrop-filter:blur(10px); box-shadow:var(--mv-shadow-lg); animation:st-float 6s ease-in-out infinite alternate }
#st-demo .st-card small { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:500; text-transform:uppercase; letter-spacing:.06em }
#st-demo .st-card b { font-size:1.125rem; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#st-demo .st-card .st-up { color:var(--mv-success); font-size:.75rem; font-weight:600 }
#st-demo .st-row { display:flex; align-items:center; gap:.5rem; font-size:.8125rem; font-weight:500 }
#st-demo .st-row span:last-child { color:var(--mv-fg-muted); font-weight:400; margin-inline-start:auto; font-size:.75rem }
#st-demo .st-c1 { left:0; top:1rem; animation-delay:-2s }
#st-demo .st-c2 { right:0; bottom:4rem; animation-duration:7.5s; animation-delay:-4s }
#st-demo .st-c3 { left:4%; bottom:.25rem; animation-duration:8.5s; animation-delay:-1s; min-width:13rem }
@keyframes st-float { from { transform:translateY(-7px) rotate(-.6deg) } to { transform:translateY(7px) rotate(.6deg) } }
#st-demo .st-spark { width:100%; height:1.75rem; overflow:visible }
#st-demo .st-spark path { fill:none; stroke:var(--mv-accent); stroke-width:2; stroke-linecap:round; stroke-linejoin:round; stroke-dasharray:120; animation:st-draw 5s var(--mv-ease-in-out) -3.4s infinite }
@keyframes st-draw { 0% { stroke-dashoffset:120 } 55%, 100% { stroke-dashoffset:0 } }
#st-demo .st-check { display:grid; place-items:center; width:1.25rem; height:1.25rem; border-radius:50%; background:color-mix(in oklch, var(--mv-success) 18%, transparent); color:var(--mv-success) }
#st-demo .st-check svg { width:.75rem; height:.75rem }
#st-demo .st-bar { position:relative; height:.375rem; border-radius:var(--mv-radius-full); background:var(--mv-bg-muted); overflow:hidden }
#st-demo .st-bar i { position:absolute; inset:0 38% 0 0; border-radius:inherit; background:linear-gradient(90deg, var(--mv-accent), color-mix(in oklch, var(--mv-accent), white 30%) 50%, var(--mv-accent)) 0 0 / 200% 100%; animation:st-shimmer 1.8s linear infinite }
@keyframes st-shimmer { to { background-position:-200% 0 } }
#st-demo .st-marquee { position:absolute; left:0; right:0; bottom:0; height:3.5rem; display:flex; align-items:center; overflow:hidden; border-top:1px solid var(--mv-border); background:color-mix(in oklch, var(--mv-bg-subtle) 70%, transparent); -webkit-mask:linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent); mask:linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent) }
#st-demo .st-track { display:flex; flex:none; gap:3rem; padding-inline-end:3rem; animation:st-scroll 28s linear infinite; color:var(--mv-fg-subtle); font-weight:600; letter-spacing:-.01em; white-space:nowrap }
#st-demo .st-track span { display:inline-flex; align-items:center; gap:.5rem }
#st-demo .st-track span::before { content:""; width:.625rem; height:.625rem; border-radius:.2rem; background:currentColor; opacity:.5 }
@keyframes st-scroll { to { transform:translateX(-100%) } }
#st-demo .st-lower { display:grid; grid-template-columns:minmax(0,1.1fr) minmax(0,1fr); gap:1rem }
#st-demo .st-panel { position:relative; display:grid; gap:1rem; padding:1.25rem 1.25rem 3.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#st-demo .st-panel header { display:flex; align-items:center; gap:.5rem }
#st-demo .st-panel header strong { font-size:.9375rem }
#st-demo .st-panel header span { margin-inline-start:auto; color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
#st-demo .st-live { position:relative; width:.5rem; height:.5rem; border-radius:50%; background:var(--mv-success) }
#st-demo .st-live::after { content:""; position:absolute; inset:0; border-radius:50%; background:var(--mv-success); animation:st-ping 1.6s var(--mv-ease-out) infinite }
@keyframes st-ping { to { transform:scale(3); opacity:0 } }
#st-demo .st-eq { display:flex; align-items:flex-end; gap:.375rem; height:6.5rem; padding-bottom:.25rem; border-bottom:1px solid var(--mv-border) }
#st-demo .st-eq i { flex:1; height:100%; border-radius:.3rem .3rem .1rem .1rem; background:linear-gradient(to top, color-mix(in oklch, var(--mv-accent) 70%, transparent), var(--mv-accent)); transform-origin:bottom; animation:st-eq 1.6s ease-in-out infinite alternate }
@keyframes st-eq { from { transform:scaleY(.18) } to { transform:scaleY(1) } }
#st-demo .st-legend { display:flex; justify-content:space-between; color:var(--mv-fg-subtle); font-size:.6875rem; font-variant-numeric:tabular-nums }
#st-demo .st-side { display:grid; gap:1rem; align-content:start; padding:1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
#st-demo .st-side h3 { margin:0; font-size:.9375rem }
#st-demo .st-side p { margin:0; color:var(--mv-fg-muted); line-height:1.5 }
#st-demo .st-readouts { display:grid; gap:.5rem }
#st-demo .st-readout { display:flex; align-items:center; gap:.75rem; padding:.625rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-surface) }
#st-demo .st-readout span { color:var(--mv-fg-muted) }
#st-demo .st-readout output { margin-inline-start:auto; font:600 .8125rem/1 var(--mv-font-mono); font-variant-numeric:tabular-nums }
#st-demo .st-readout output small { color:var(--mv-fg-subtle); font-weight:500 }
#st-demo .st-toggle { display:flex; align-items:center; gap:.625rem; cursor:pointer; font-weight:500 }
@media (prefers-reduced-motion: reduce) {
#st-demo .st-blob, #st-demo .st-spin, #st-demo .st-mark, #st-demo .st-card, #st-demo .st-spark path, #st-demo .st-bar i, #st-demo .st-track, #st-demo .st-live::after, #st-demo .st-eq i { animation:none }
}
:where([data-motion="reduce"]) #st-demo :is(.st-blob, .st-spin, .st-mark, .st-card, .st-spark path, .st-bar i, .st-track, .st-eq i), :where([data-motion="reduce"]) #st-demo .st-live::after { animation:none }
@media (max-width: 760px) {
#st-demo .st-hero { grid-template-columns:1fr; padding:1.5rem 1.25rem 5rem }
#st-demo .st-lower { grid-template-columns:1fr }
}
</style>
<mv-still-time class="st-hero" id="st-hero" indicator>
<div class="st-sky" aria-hidden="true"><span class="st-blob st-b1"></span><span class="st-blob st-b2"></span><span class="st-blob st-b3"></span></div>
<div class="st-copy">
<span class="mv-badge" data-variant="secondary">New · Focus mode</span>
<h2>Analytics that move when you do</h2>
<p>Your dashboard stays calm while you read and comes alive the moment you reach for it. Move the pointer over this panel.</p>
<div class="st-cta">
<button class="mv-button">Start free trial</button>
<button class="mv-button" data-variant="outline">Book a demo</button>
</div>
</div>
<div class="st-visual" aria-hidden="true">
<div class="st-orbit">
<span class="st-ring"></span>
<span class="st-ring st-ring-2"></span>
<span class="st-spin"><i class="st-dot" style="rotate:20deg"></i><i class="st-dot" style="rotate:140deg"></i><i class="st-dot" style="rotate:260deg"></i></span>
<span class="st-spin st-spin-2"><i class="st-dot" style="rotate:80deg"></i><i class="st-dot" style="rotate:260deg"></i></span>
<span class="st-mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V9M10 19V5M16 19v-7M22 19H2"/></svg></span>
</div>
<figure class="st-card st-c1">
<small>Revenue · this week</small>
<div class="st-row"><b>$48,210</b><span class="st-up">↑ 12.4%</span></div>
<svg class="st-spark" viewBox="0 0 120 28" preserveAspectRatio="none"><path d="M2 22 18 18 30 20 44 12 58 15 72 8 86 11 100 5 118 3"/></svg>
</figure>
<figure class="st-card st-c2">
<div class="st-row"><span class="st-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg></span>Deploy succeeded<span>42s</span></div>
<div class="st-row"><span class="mv-spinner" style="--mv-spinner-size:1rem;color:var(--mv-accent)"></span>Warming edge cache<span>3 of 5</span></div>
</figure>
<figure class="st-card st-c3">
<div class="st-row">Syncing 1,284 events<span>62%</span></div>
<div class="st-bar"><i></i></div>
</figure>
</div>
<div class="st-marquee" aria-hidden="true">
<div class="st-track"><span>Kestrel</span><span>Acme Logistics</span><span>Halden Studio</span><span>Blue Harbor</span><span>Fernhollow Health</span><span>Vertex Labs</span><span>Orbital Freight</span></div>
<div class="st-track"><span>Kestrel</span><span>Acme Logistics</span><span>Halden Studio</span><span>Blue Harbor</span><span>Fernhollow Health</span><span>Vertex Labs</span><span>Orbital Freight</span></div>
</div>
</mv-still-time>
<div class="st-lower">
<mv-still-time class="st-panel" id="st-panel" idle-freeze indicator>
<header><i class="st-live" aria-hidden="true"></i><strong>Live traffic</strong><span>1,284 visitors now</span></header>
<div class="st-eq" aria-hidden="true">
<i style="animation-delay:-.2s"></i><i style="animation-delay:-1.1s"></i><i style="animation-delay:-.6s"></i><i style="animation-delay:-1.4s"></i><i style="animation-delay:-.3s"></i><i style="animation-delay:-.9s"></i><i style="animation-delay:-1.5s"></i><i style="animation-delay:-.5s"></i><i style="animation-delay:-1.2s"></i><i style="animation-delay:-.1s"></i><i style="animation-delay:-.8s"></i><i style="animation-delay:-1.3s"></i><i style="animation-delay:-.4s"></i><i style="animation-delay:-1s"></i>
</div>
<div class="st-legend" aria-hidden="true"><span>2:00 PM</span><span>2:30 PM</span><span>3:00 PM</span></div>
</mv-still-time>
<div class="st-side">
<h3>Time follows your hand</h3>
<p>Keep still: the hero crawls at ×0.03 and the traffic panel freezes completely. Move, scroll or type and both speed up to ×1.00.</p>
<div class="st-readouts">
<div class="st-readout"><span>Hero</span><output id="st-hero-rate">×0.03 <small>→ ×1.00</small></output></div>
<div class="st-readout"><span>Traffic panel</span><output id="st-panel-rate">×0.00 <small>→ ×1.00</small></output></div>
</div>
<label class="st-toggle"><input type="checkbox" role="switch" class="mv-switch" id="st-freeze" checked> Freeze the panel completely when idle</label>
</div>
</div>
</div>
<script type="module">
const root = document.getElementById("st-demo");
if (root) {
const show = (el, out) => {
const paint = (rate) => {
out.textContent = `×${rate.toFixed(2)} `;
const max = document.createElement("small");
max.textContent = "→ ×1.00";
out.append(max);
};
el.addEventListener("mv-timerate", (e) => paint(e.detail.rate));
customElements.whenDefined("mv-still-time").then(() => paint(el.rate ?? 0));
};
const hero = root.querySelector("#st-hero");
const panel = root.querySelector("#st-panel");
show(hero, root.querySelector("#st-hero-rate"));
show(panel, root.querySelector("#st-panel-rate"));
root.querySelector("#st-freeze").addEventListener("change", (e) => {
panel.idleFreeze = e.target.checked;
});
}
</script>API
Attributes
| Name | 类型 | Default | Description |
|---|---|---|---|
min-rate | number | 0.03 | Time rate when the user is still (0 … max-rate). Animations crawl at this speed instead of stopping, so the scene never looks broken. |
max-rate | number | 1 | Time rate under full activity. Values above 1 make motion faster than authored. |
idle-freeze | boolean | false | Exact 0 when idle: animations hold their current frame (playbackRate 0) and governed videos are paused, then resume where they were. |
scope | region | page | region | Where activity is measured: only over the element (pointer, touch, wheel, keys and focus inside it, scrolling of its descendants), or over the whole page (including window scroll speed). |
sensitivity | number | 1 | Multiplies pointer and scroll speeds before they are mapped to activity (2 = half the speed is needed to reach full time). |
video | muted | all | none | muted | Which <video> descendants follow the rate (clamped to the 0.0625-16 range browsers support). Muted only by default, so audio is never slowed down. |
indicator | boolean | false | Shows a small decorative time gauge in the bottom corner (ring on a log scale + “×0.03”), aria-hidden, plus a visually hidden sentence explaining the behavior. |
data-still-time-ignore | boolean (on a descendant) | Excludes this subtree: its animations and videos keep their own speed (use it for spinners, countdowns and other honest status). |
Properties
| Name | 类型 | Description |
|---|---|---|
rate | number | Current time rate (read-only). |
time | number (s) | Scaled clock: seconds that advance at the current rate (read-only). Drive a canvas or shader loop with it to follow the governor exactly. |
minRate, maxRate, idleFreeze, scope, sensitivity, video, indicator | reflected | Mirror the attributes (scope is read when the element connects). |
Methods
| Name | Description |
|---|---|
nudge(amount = 0.6) | Feeds activity manually (0 … 1), e.g. from a gamepad, a voice command or a custom gesture. |
Events
| Name | Description |
|---|---|
mv-timerate | Throttled to ~10 per second while the rate changes, plus one when it comes to rest. detail: { rate, activity, resting }. |
Content structure
| Name | Description |
|---|---|
(content) | Any content with ambient motion: CSS keyframes, transitions, Web Animations (other components included), muted <video>. Script-created animations are picked up within ~1.5 s, CSS ones as soon as they start. |
CSS classes
| Name | Description |
|---|---|
mv-still-time-gauge | Indicator pill (aria-hidden); data-level = frozen | still | moving | live. |
mv-still-time-arc / -track / -hand / -value | Parts of the gauge: filled ring, its track, the clock hand and the “×0.03” text. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-time-rate | 1 | Set on the element to the current rate (3 decimals) while it changes: read it in calc() for effects that are not animations (blur, glow, transition-duration of your own). |
--mv-still-time-color | var(--mv-accent) | Color of the gauge arc. |
--mv-still-time-inset | var(--mv-space-3) | Distance of the gauge from the bottom and inline-end edges. |
Accessibility
Purely presentational: it adds no roles, no focus stops and never moves focus. It only slows motion down, so it reduces distraction for people who read slowly or are sensitive to motion, while keyboard users are treated like pointer users (key presses and focus changes count as activity, so nobody gets a frozen interface because they do not use a mouse). The indicator gauge is aria-hidden; when indicator is set, a visually hidden sentence explains that ambient animations follow your activity (no live announcements, which would be noise). Honest status animations (spinners, countdowns, progress) can be excluded with data-still-time-ignore. Reduced motion (system or data-motion="reduce"): the governor does not react at all and keeps the rate at min-rate (0 with idle-freeze); well-behaved content already stops its own continuous animations in that mode. Performance: the animation set is refreshed on animationstart / transitionrun events plus a 1.5 s sweep (getAnimations({ subtree: true })), never per frame; the frameLoop runs only while the rate is changing and sleeps at rest (no work at all while nobody moves), pauses offscreen and in hidden tabs; playbackRate is updated with updatePlaybackRate (no restart, no jump) only when it actually changed; video rates are updated at ~10 Hz. On disconnect every governed animation and video is restored to its natural rate and resumed if it was paused. Known limit: the visually hidden sentence added with indicator is English only and cannot be overridden yet.