springSpring
弹簧A simulated oscillator approaches a target according to stiffness, damping, mass, and current velocity.
由张力、质量和阻尼等物理参数驱动的运动,不依赖固定时长。
springTune a physical spring response with stiffness, damping, mass, and initial velocity.
A simulated oscillator approaches a target according to stiffness, damping, mass, and current velocity.
Implement Spring: Tune a physical spring response with stiffness, damping, mass, and initial velocity. Use Stiffness 220, Damping 24, Mass 1x, Initial velocity 0, Distance 48px. Trigger it once when state changes and keep it replayable. Use semantic HTML, scope CSS to .motion-demo--spring, and mount the provided vanilla JavaScript interaction runtime. Provide equivalent low-motion behavior for prefers-reduced-motion and .force-reduced-motion.
<div class="motion-demo motion-demo--spring" data-motion="spring" aria-label="Spring demo">
<div class="motion-replay-scene"><button class="motion-surface" type="button" data-spring-target><strong>Spring</strong><span class="motion-line"></span><span class="motion-line"></span></button><div class="motion-action-row"><button class="motion-replay-button" type="button" data-motion-replay>Replay motion</button></div></div>
</div>.motion-demo.motion-demo--spring {
box-sizing: border-box;
position: relative;
display: grid;
place-items: center;
width: min(100%, 34rem);
min-height: 15rem;
padding: 2rem;
overflow: hidden;
color: #18181b;
background: #fafafa;
border: 1px solid #e4e4e7;
border-radius: 16px;
isolation: isolate;
}
.motion-demo.motion-demo--spring *, .motion-demo.motion-demo--spring *::before, .motion-demo.motion-demo--spring *::after {
box-sizing: border-box;
}
.motion-demo.motion-demo--spring .motion-surface {
position: relative;
display: grid;
gap: 0.65rem;
width: min(100%, 18rem);
padding: 1.25rem;
background: #ffffff;
border: 1px solid #e4e4e7;
border-radius: 16px;
box-shadow: none;
}
.motion-demo.motion-demo--spring .motion-surface strong { font-weight: 500; }
.motion-demo.motion-demo--spring .motion-line {
display: block;
width: 100%;
height: 0.5rem;
background: #e4e4e7;
border-radius: 999px;
}
.motion-demo.motion-demo--spring .motion-line:last-child { width: 62%; }
.motion-demo.motion-demo--spring .motion-button {
position: relative;
min-height: 2.75rem;
padding: 0.75rem 1.1rem;
color: white;
font: inherit;
font-weight: 700;
background: #18181b;
border: 0;
border-radius: 999px;
cursor: pointer;
}
.motion-demo.motion-demo--spring .motion-button:focus-visible { outline: 3px solid rgb(37 99 235 / 32%); outline-offset: 3px; }
.motion-demo.motion-demo--spring .motion-action-row {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 0.85rem;
}
.motion-demo.motion-demo--spring .motion-replay-scene {
width: min(100%, 22rem);
display: grid;
place-items: center;
}
.motion-demo.motion-demo--spring .motion-pausable-scene {
width: min(100%, 22rem);
display: grid;
place-items: center;
}
.motion-demo.motion-demo--spring button.motion-surface {
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
}
.motion-demo.motion-demo--spring .motion-replay-button,
.motion-demo.motion-demo--spring .motion-undo-button,
.motion-demo.motion-demo--spring .motion-dismiss-button,
.motion-demo.motion-demo--spring .motion-pause-button {
min-height: 2.75rem;
padding: 0.65rem 0.9rem;
color: #18181b;
font: 650 0.78rem/1 ui-sans-serif, system-ui, sans-serif;
background: #ffffff;
border: 1px solid #d4d4d8;
border-radius: 8px;
cursor: pointer;
}
.motion-demo.motion-demo--spring .motion-replay-button:focus-visible,
.motion-demo.motion-demo--spring .motion-undo-button:focus-visible,
.motion-demo.motion-demo--spring .motion-dismiss-button:focus-visible,
.motion-demo.motion-demo--spring .motion-pause-button:focus-visible,
.motion-demo.motion-demo--spring [data-reorder-item]:focus-visible,
.motion-demo.motion-demo--spring [data-swipe-target]:focus-visible {
outline: 3px solid rgb(37 99 235 / 32%);
outline-offset: 3px;
}
.motion-demo.motion-demo--spring .motion-status {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.motion-demo.motion-demo--spring .motion-ripple-ink {
position: absolute;
width: 12px;
height: 12px;
pointer-events: none;
background: rgb(255 255 255 / 72%);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0.95);
opacity: 0;
}
.motion-demo.motion-demo--spring .motion-state-stack { position: relative; width: min(100%, 18rem); height: 9rem; }
.motion-demo.motion-demo--spring .motion-state { position: absolute; inset: 0; display: grid; place-items: center; padding: 1rem; background: #ffffff; border: 1px solid #e4e4e7; border-radius: 16px; }
.motion-demo.motion-demo--spring .motion-state--to { background: #18181b; color: white; }
.motion-demo.motion-demo--spring .motion-scroll-track > span { color: #71717a; font: 600 0.72rem/1.2 ui-monospace, monospace; letter-spacing: 0.08em; text-transform: uppercase; }
.motion-demo.motion-demo--spring .motion-orbit-item { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; color: white; background: #2563eb; border-radius: 50%; }
.motion-demo.motion-demo--spring .motion-text-stack { position: relative; width: min(100%, 18rem); min-height: 4rem; }
.motion-demo.motion-demo--spring svg { width: min(100%, 20rem); height: auto; }
.motion-demo.motion-demo--spring .motion-surface[data-spring-target] { cursor: pointer; will-change: transform, opacity; }
.motion-demo.motion-demo--spring .motion-surface[data-spring-target]:focus-visible { outline: 3px solid rgb(37 99 235 / 32%); outline-offset: 3px; }
@keyframes motion-spring-reduced-fade {
from { opacity: 0.84; }
to { opacity: 1; }
}
.force-reduced-motion .motion-demo.motion-demo--spring {
animation: motion-spring-reduced-fade 160ms cubic-bezier(0.23, 1, 0.32, 1) both !important;
}
.force-reduced-motion .motion-demo.motion-demo--spring *,
.force-reduced-motion .motion-demo.motion-demo--spring *::before,
.force-reduced-motion .motion-demo.motion-demo--spring *::after {
animation: none !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-property: opacity, color, background-color, border-color !important;
transition-duration: 160ms !important;
transition-delay: 0ms !important;
scroll-behavior: auto !important;
}
.force-reduced-motion .motion-demo.motion-demo--spring .motion-button,
.force-reduced-motion .motion-demo.motion-demo--spring [data-reorder-item],
.force-reduced-motion .motion-demo.motion-demo--spring [data-swipe-target],
.force-reduced-motion .motion-demo.motion-demo--spring [data-spring-target] {
transform: none !important;
}
.force-reduced-motion .motion-demo.motion-demo--spring .motion-path { stroke-dashoffset: 0 !important; }
.force-reduced-motion .motion-demo.motion-demo--spring .motion-word--from { opacity: 0 !important; visibility: hidden; }
.force-reduced-motion .motion-demo.motion-demo--spring .motion-word:not(.motion-word--from) { opacity: 1 !important; filter: none !important; transform: none !important; }
@media (prefers-reduced-motion: reduce) {
.motion-demo.motion-demo--spring {
animation: motion-spring-reduced-fade 160ms cubic-bezier(0.23, 1, 0.32, 1) both !important;
}
.motion-demo.motion-demo--spring *, .motion-demo.motion-demo--spring *::before, .motion-demo.motion-demo--spring *::after {
animation: none !important;
animation-delay: 0ms !important;
animation-iteration-count: 1 !important;
transition-property: opacity, color, background-color, border-color !important;
transition-duration: 160ms !important;
transition-delay: 0ms !important;
scroll-behavior: auto !important;
}
.motion-demo.motion-demo--spring .motion-button,
.motion-demo.motion-demo--spring [data-reorder-item],
.motion-demo.motion-demo--spring [data-swipe-target],
.motion-demo.motion-demo--spring [data-spring-target] {
transform: none !important;
}
.motion-demo.motion-demo--spring .motion-path { stroke-dashoffset: 0 !important; }
.motion-demo.motion-demo--spring .motion-word--from { opacity: 0 !important; visibility: hidden; }
.motion-demo.motion-demo--spring .motion-word:not(.motion-word--from) { opacity: 1 !important; filter: none !important; transform: none !important; }
}(() => {
const config = {
"id": "spring",
"duration": 180,
"distance": 48,
"resistance": 65,
"position": 50,
"stiffness": 220,
"damping": 24,
"mass": 1,
"velocity": 0,
"rippleSize": 220,
"rippleOpacity": 24,
"dragScale": 103,
"autoplay": false
};
const mount = (root, config) => {
const cleanups = [];
const addCleanup = (callback) => cleanups.push(callback);
const cleanup = () => {
for (let index = cleanups.length - 1; index >= 0; index -= 1) {
try { cleanups[index](); } catch { /* keep cleanup reentrant */ }
}
cleanups.length = 0;
};
const on = (target, type, listener, options) => {
target.addEventListener(type, listener, options);
addCleanup(() => target.removeEventListener(type, listener, options));
};
const reduced = () => root.closest(".force-reduced-motion") !== null ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const watchReduced = (callback) => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const notify = () => callback(reduced());
media.addEventListener("change", notify);
const observer = typeof MutationObserver === "undefined" ? null : new MutationObserver(notify);
let ancestor = root;
while (observer && ancestor) {
observer.observe(ancestor, { attributes: true, attributeFilter: ["class"] });
ancestor = ancestor.parentElement;
}
addCleanup(() => {
media.removeEventListener("change", notify);
if (observer) observer.disconnect();
});
};
const target = root.querySelector("[data-spring-target]");
const replay = root.querySelector("[data-motion-replay]");
if (!target || !replay) return cleanup;
const initialTransform = target.style.transform;
const initialOpacity = target.style.opacity;
let frame = 0;
let fade = null;
let position = config.distance;
let velocity = config.velocity * 10;
let springTarget = 0;
let configuredDistance = config.distance;
let previous = 0;
const stop = () => {
cancelAnimationFrame(frame);
frame = 0;
if (fade) fade.cancel();
fade = null;
};
const settleReduced = (feedback) => {
stop();
position = 0;
velocity = 0;
springTarget = 0;
target.style.transform = "";
target.style.opacity = "";
if (feedback) {
fade = target.animate([{ opacity: 0.72 }, { opacity: 1 }], {
duration: 160,
easing: "cubic-bezier(0.23, 1, 0.32, 1)"
});
fade.addEventListener("finish", () => { fade = null; }, { once: true });
}
};
const renderPosition = (amplitude) => {
const displacement = position - springTarget;
const progress = Math.min(1, Math.abs(displacement) / amplitude);
target.style.transform = "translate3d(0, " + position.toFixed(3) + "px, 0) scale(" + (1 - progress * 0.06).toFixed(4) + ")";
target.style.opacity = String(1 - progress * 0.28);
};
const play = () => {
if (reduced()) { settleReduced(true); return; }
if (fade) fade.cancel();
fade = null;
const stiffness = Math.max(1, config.stiffness);
const damping = Math.max(0, config.damping);
const mass = Math.max(0.1, config.mass);
const distance = config.distance;
if (!frame && Math.abs(position - configuredDistance) < 0.08) {
position = distance;
if (Math.abs(springTarget - configuredDistance) < 0.08) springTarget = distance;
}
configuredDistance = distance;
const amplitude = Math.max(1, Math.abs(distance));
if (frame) {
springTarget = Math.abs(springTarget) < 0.01 ? distance : 0;
return;
}
if (Math.abs(position) < 0.08) {
position = distance;
velocity = config.velocity * 10;
springTarget = 0;
} else if (Math.abs(position - distance) < 0.08) {
springTarget = 0;
}
previous = performance.now();
renderPosition(amplitude);
const step = (now) => {
if (reduced()) { settleReduced(true); return; }
const elapsed = Math.min(0.032, Math.max(0.001, (now - previous) / 1000));
previous = now;
const stepCount = Math.max(1, Math.ceil(elapsed / (1 / 120)));
const delta = elapsed / stepCount;
for (let index = 0; index < stepCount; index += 1) {
const displacement = position - springTarget;
const acceleration = (-stiffness * displacement - damping * velocity) / mass;
velocity += acceleration * delta;
position += velocity * delta;
}
renderPosition(amplitude);
if (Math.abs(position - springTarget) < 0.08 && Math.abs(velocity) < 0.08) {
position = springTarget;
velocity = 0;
target.style.transform = springTarget === 0 ? "" : "translate3d(0, " + String(springTarget) + "px, 0)";
target.style.opacity = "";
frame = 0;
return;
}
frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
};
const restartLatest = () => {
stop();
configuredDistance = config.distance;
position = config.distance;
velocity = config.velocity * 10;
springTarget = 0;
play();
};
addCleanup(() => {
stop();
position = config.distance;
velocity = config.velocity * 10;
springTarget = 0;
target.style.transform = initialTransform;
target.style.opacity = initialOpacity;
});
watchReduced((value) => {
if (value && frame) settleReduced(true);
if (!value && fade) {
fade.cancel();
fade = null;
target.style.opacity = "";
}
});
on(replay, "click", restartLatest);
on(target, "click", play);
if (config.autoplay) restartLatest();
return cleanup;
};
document.querySelectorAll("[data-motion=\"spring\"]").forEach((root) => {
const cleanupKey = "__motionLexiconCleanup";
if (typeof root[cleanupKey] === "function") root[cleanupKey]();
root[cleanupKey] = mount(root, config);
});
})();springA simulated oscillator approaches a target according to stiffness, damping, mass, and current velocity.
由张力、质量和阻尼等物理参数驱动的运动,不依赖固定时长。
stiffness-tensionStiffness determines the restoring force applied for a given distance from the spring's target.
弹簧拉向目标的力度,数值越高,响应越干脆。
Stiffness / Tension controls how strongly a spring pulls toward its target; it is a spring-model parameter.
dampingDamping removes energy from a spring over time, controlling oscillation and settlement.
弹簧稳定下来的速度;阻尼越低,回弹和振荡越明显。
Damping controls how quickly oscillation settles; Spring is the full physical model that includes damping.
massMass sets inertia in the spring equation, influencing acceleration and the perceived weight of motion.
动画元素呈现出的重量;质量越大,运动越慢、越沉。
Mass controls perceived weight and inertia; it works with stiffness and damping to shape a spring response.
bounceA spring crosses its target one or more times before its oscillation converges.
弹簧越过目标后再稳定下来,为动作增加活泼感。
Bounce is a spring overshooting and settling, commonly produced by lower damping with suitable stiffness.
perceptual-durationThe perceived duration ends when remaining spring movement falls below a visually meaningful threshold.
弹簧在感知上完成所需的时长,尽管底层仍在进行微小的收敛。
Perceptual duration describes when a spring feels finished to the user; it differs from a fixed CSS duration.
momentumStored velocity continues to influence motion after direct manipulation ends.
保留速度并继续前进的运动,常见于拖拽释放或动画中断之后。
Momentum preserves velocity after release or interruption; a spring can receive and carry that velocity.
velocityVelocity records both speed and direction, allowing a new transition to inherit current movement.
元素移动的快慢与方向;弹簧会在中断时把它带入下一段动画,使被快速甩动的元素保留速度。
Velocity records speed and direction; a spring can use it as the initial condition for the next response.
interruptible-animationAn in-progress transition accepts a new target while retaining continuity of position and velocity.
动画可以在运行途中平滑地改变目标,无需等待当前动作完成。
Interruptible animation names the ability to retarget motion smoothly in flight; a spring is one way to implement it.
Use high damping or immediate arrival, removing bounce and prolonged micro-oscillation.
| Parameter | Range / options | Default | Purpose |
|---|---|---|---|
stiffness | 60–500 | 220 | Adjusts stiffness. |
damping | 5–60 | 24 | Adjusts damping. |
mass | 0.5x–3x | 1x | Adjusts mass. |
velocity | -20–20 | 0 | Adjusts initial velocity. |
distance | 0px–160px | 48px | Controls how far the element travels. |