text-morphText morph
文字形变Characters or glyph shapes transition between text states to focus attention on updated content.
文字变化时逐字符播放动画,把注意力引向新值。
text-morphTransition between similarly sized short labels while preserving reading position.
Characters or glyph shapes transition between text states to focus attention on updated content.
Implement Text morph: Transition between similarly sized short labels while preserving reading position. Use Duration 240ms, Transition blur 8px, Delay 120ms, Easing soft. Trigger it once when state changes and keep it replayable. Use semantic HTML, scope CSS to .motion-demo--text-morph, 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--text-morph" data-motion="text-morph" aria-label="Text morph demo">
<div class="motion-replay-scene">
<div class="motion-text-stack" role="status" aria-label="Text morph"><span class="motion-word motion-word--from" aria-hidden="true">Plan</span><span class="motion-word">Build</span></div>
<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--text-morph {
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--text-morph *, .motion-demo.motion-demo--text-morph *::before, .motion-demo.motion-demo--text-morph *::after {
box-sizing: border-box;
}
.motion-demo.motion-demo--text-morph .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--text-morph .motion-surface strong { font-weight: 500; }
.motion-demo.motion-demo--text-morph .motion-line {
display: block;
width: 100%;
height: 0.5rem;
background: #e4e4e7;
border-radius: 999px;
}
.motion-demo.motion-demo--text-morph .motion-line:last-child { width: 62%; }
.motion-demo.motion-demo--text-morph .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--text-morph .motion-button:focus-visible { outline: 3px solid rgb(37 99 235 / 32%); outline-offset: 3px; }
.motion-demo.motion-demo--text-morph .motion-action-row {
display: flex;
justify-content: center;
gap: 0.5rem;
margin-top: 0.85rem;
}
.motion-demo.motion-demo--text-morph .motion-replay-scene {
width: min(100%, 22rem);
display: grid;
place-items: center;
}
.motion-demo.motion-demo--text-morph .motion-pausable-scene {
width: min(100%, 22rem);
display: grid;
place-items: center;
}
.motion-demo.motion-demo--text-morph button.motion-surface {
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
}
.motion-demo.motion-demo--text-morph .motion-replay-button,
.motion-demo.motion-demo--text-morph .motion-undo-button,
.motion-demo.motion-demo--text-morph .motion-dismiss-button,
.motion-demo.motion-demo--text-morph .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--text-morph .motion-replay-button:focus-visible,
.motion-demo.motion-demo--text-morph .motion-undo-button:focus-visible,
.motion-demo.motion-demo--text-morph .motion-dismiss-button:focus-visible,
.motion-demo.motion-demo--text-morph .motion-pause-button:focus-visible,
.motion-demo.motion-demo--text-morph [data-reorder-item]:focus-visible,
.motion-demo.motion-demo--text-morph [data-swipe-target]:focus-visible {
outline: 3px solid rgb(37 99 235 / 32%);
outline-offset: 3px;
}
.motion-demo.motion-demo--text-morph .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--text-morph .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--text-morph .motion-state-stack { position: relative; width: min(100%, 18rem); height: 9rem; }
.motion-demo.motion-demo--text-morph .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--text-morph .motion-state--to { background: #18181b; color: white; }
.motion-demo.motion-demo--text-morph .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--text-morph .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--text-morph .motion-text-stack { position: relative; width: min(100%, 18rem); min-height: 4rem; }
.motion-demo.motion-demo--text-morph svg { width: min(100%, 20rem); height: auto; }
.motion-demo.motion-demo--text-morph .motion-word { position: absolute; font-size: 2rem; font-weight: 700; animation: motion-text-morph 240ms cubic-bezier(0.23, 1, 0.32, 1) 120ms both; }
.motion-demo.motion-demo--text-morph .motion-word--from { animation-name: motion-text-morph-out; }
@keyframes motion-text-morph { from { opacity: 0; filter: blur(8px); transform: translateY(0.35em); } to { opacity: 1; filter: blur(0); transform: none; } }
@keyframes motion-text-morph-out { to { opacity: 0; filter: blur(8px); transform: translateY(-0.35em); } }
@keyframes motion-text-morph-reduced-fade {
from { opacity: 0.84; }
to { opacity: 1; }
}
.force-reduced-motion .motion-demo.motion-demo--text-morph {
animation: motion-text-morph-reduced-fade 160ms cubic-bezier(0.23, 1, 0.32, 1) both !important;
}
.force-reduced-motion .motion-demo.motion-demo--text-morph *,
.force-reduced-motion .motion-demo.motion-demo--text-morph *::before,
.force-reduced-motion .motion-demo.motion-demo--text-morph *::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--text-morph .motion-button,
.force-reduced-motion .motion-demo.motion-demo--text-morph [data-reorder-item],
.force-reduced-motion .motion-demo.motion-demo--text-morph [data-swipe-target],
.force-reduced-motion .motion-demo.motion-demo--text-morph [data-spring-target] {
transform: none !important;
}
.force-reduced-motion .motion-demo.motion-demo--text-morph .motion-path { stroke-dashoffset: 0 !important; }
.force-reduced-motion .motion-demo.motion-demo--text-morph .motion-word--from { opacity: 0 !important; visibility: hidden; }
.force-reduced-motion .motion-demo.motion-demo--text-morph .motion-word:not(.motion-word--from) { opacity: 1 !important; filter: none !important; transform: none !important; }
@media (prefers-reduced-motion: reduce) {
.motion-demo.motion-demo--text-morph {
animation: motion-text-morph-reduced-fade 160ms cubic-bezier(0.23, 1, 0.32, 1) both !important;
}
.motion-demo.motion-demo--text-morph *, .motion-demo.motion-demo--text-morph *::before, .motion-demo.motion-demo--text-morph *::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--text-morph .motion-button,
.motion-demo.motion-demo--text-morph [data-reorder-item],
.motion-demo.motion-demo--text-morph [data-swipe-target],
.motion-demo.motion-demo--text-morph [data-spring-target] {
transform: none !important;
}
.motion-demo.motion-demo--text-morph .motion-path { stroke-dashoffset: 0 !important; }
.motion-demo.motion-demo--text-morph .motion-word--from { opacity: 0 !important; visibility: hidden; }
.motion-demo.motion-demo--text-morph .motion-word:not(.motion-word--from) { opacity: 1 !important; filter: none !important; transform: none !important; }
}(() => {
const config = {
"id": "text-morph",
"duration": 240,
"distance": 72,
"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 replay = root.querySelector("[data-motion-replay]");
if (!replay) return cleanup;
const restart = () => {
root.getAnimations({ subtree: true }).forEach((animation) => {
animation.cancel();
animation.currentTime = 0;
animation.play();
});
};
addCleanup(() => root.getAnimations({ subtree: true }).forEach((animation) => {
if (animation.playState === "paused") animation.play();
}));
on(replay, "click", restart);
return cleanup;
};
document.querySelectorAll("[data-motion=\"text-morph\"]").forEach((root) => {
const cleanupKey = "__motionLexiconCleanup";
if (typeof root[cleanupKey] === "function") root[cleanupKey]();
root[cleanupKey] = mount(root, config);
});
})();text-morphCharacters or glyph shapes transition between text states to focus attention on updated content.
文字变化时逐字符播放动画,把注意力引向新值。
Replace the full text immediately and use static highlighting or one short fade to signal change.
| Parameter | Range / options | Default | Purpose |
|---|---|---|---|
duration | 80ms–1200ms | 240ms | Controls the time required for one motion cycle. |
blur | 0px–20px | 8px | Adjusts transition blur. |
delay | 0ms–600ms | 120ms | Controls the wait before motion begins. |
ease | Soft / Snap / Calm / Linear | Soft | Controls how velocity changes over time. |