30 CSS Text Hover Effects26 / 30
Text Marquee Speed-Up on Hover
The streetwear ticker with the acceleration everyone gets wrong: the loop is pure CSS (duplicated content, translateX to -50%, seamless forever), and hovering makes it SURGE smoothly — because 16 lines of JS grab the CSS animation through the Web Animations API and call updatePlaybackRate(4), which velocity-matches instead of teleporting. Changing animation-duration in CSS snaps the phase (the naive approach visibly jumps); updatePlaybackRate is the engine-level 'same tape, faster motor'. Includes the legally-required-by-taste pause button, wired to the same API.
Published
The code
<section class="cth-26" aria-label="Marquee speed up on hover demo">
<div class="cth-26__stage">
<div class="cth-26__scene">
<header class="cth-26__head">
<h1>ARCHIVE / 004</h1>
<button class="cth-26__pause" type="button" aria-pressed="false">Pause tickers</button>
</header>
<div class="cth-26__bar cth-26__bar--a" id="cth26-top">
<div class="cth-26__track" data-marquee>
<p class="cth-26__group">NEW DROP 004 ◆ FREE SHIPPING OVER $120 ◆ STORE OPENS FRI 10:00 ◆ NO RESTOCKS ◆ </p>
<p class="cth-26__group" aria-hidden="true">NEW DROP 004 ◆ FREE SHIPPING OVER $120 ◆ STORE OPENS FRI 10:00 ◆ NO RESTOCKS ◆ </p>
</div>
</div>
<div class="cth-26__bar cth-26__bar--b">
<div class="cth-26__track cth-26__track--rev" data-marquee>
<p class="cth-26__group">WORLDWIDE ◇ 100% RECYCLED FLEECE ◇ SIZE RUNS BIG ◇ MEMBERS EARLY ACCESS THU ◇ </p>
<p class="cth-26__group" aria-hidden="true">WORLDWIDE ◇ 100% RECYCLED FLEECE ◇ SIZE RUNS BIG ◇ MEMBERS EARLY ACCESS THU ◇ </p>
</div>
</div>
<p class="cth-26__hint">Hover a strip: <code>anim.updatePlaybackRate(4)</code> surges it with zero phase jump — the thing a CSS duration swap can't do. Counter-flow directions are the streetwear tell.</p>
</div>
</div>
</section><section class="cth-26" aria-label="Marquee speed up on hover demo">
<div class="cth-26__stage">
<div class="cth-26__scene">
<header class="cth-26__head">
<h1>ARCHIVE / 004</h1>
<button class="cth-26__pause" type="button" aria-pressed="false">Pause tickers</button>
</header>
<div class="cth-26__bar cth-26__bar--a" id="cth26-top">
<div class="cth-26__track" data-marquee>
<p class="cth-26__group">NEW DROP 004 ◆ FREE SHIPPING OVER $120 ◆ STORE OPENS FRI 10:00 ◆ NO RESTOCKS ◆ </p>
<p class="cth-26__group" aria-hidden="true">NEW DROP 004 ◆ FREE SHIPPING OVER $120 ◆ STORE OPENS FRI 10:00 ◆ NO RESTOCKS ◆ </p>
</div>
</div>
<div class="cth-26__bar cth-26__bar--b">
<div class="cth-26__track cth-26__track--rev" data-marquee>
<p class="cth-26__group">WORLDWIDE ◇ 100% RECYCLED FLEECE ◇ SIZE RUNS BIG ◇ MEMBERS EARLY ACCESS THU ◇ </p>
<p class="cth-26__group" aria-hidden="true">WORLDWIDE ◇ 100% RECYCLED FLEECE ◇ SIZE RUNS BIG ◇ MEMBERS EARLY ACCESS THU ◇ </p>
</div>
</div>
<p class="cth-26__hint">Hover a strip: <code>anim.updatePlaybackRate(4)</code> surges it with zero phase jump — the thing a CSS duration swap can't do. Counter-flow directions are the streetwear tell.</p>
</div>
</div>
</section>.cth-26,
.cth-26 *,
.cth-26 *::before,
.cth-26 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cth-26 {
--bg: oklch(0.96 0.005 90);
--ink: oklch(0.17 0.005 90);
--paper: oklch(0.99 0.002 90);
--acc: oklch(0.62 0.21 30);
--mut: oklch(0.48 0.01 90);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
}
.cth-26__stage {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
background: var(--bg);
padding: clamp(20px,4vw,56px) 0;
container: cth26/inline-size;
overflow: clip;
}
.cth-26__scene {
width: 100%;
display: flex;
flex-direction: column;
gap: clamp(22px,4cqi,36px);
}
.cth-26__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 clamp(20px,5cqi,64px);
}
.cth-26__head h1 {
font-size: clamp(30px,6cqi,54px);
font-weight: 900;
letter-spacing: -.04em;
}
.cth-26__pause {
min-height: 46px;
padding: 0 20px;
border-radius: 99px;
border: 2px solid var(--ink);
background: var(--paper);
font-size: 13px;
font-weight: 800;
letter-spacing: .06em;
text-transform: uppercase;
cursor: pointer;
transition: background .25s,color .25s;
}
.cth-26__pause:hover {
background: var(--ink);
color: var(--paper);
}
.cth-26__pause[aria-pressed="true"] {
background: var(--acc);
border-color: var(--acc);
color: var(--paper);
}
.cth-26__pause:focus-visible {
outline: 3px solid var(--acc);
outline-offset: 3px;
}
.cth-26__bar {
overflow: clip;
padding: 14px 0;
cursor: pointer;
}
.cth-26__bar--a {
background: var(--ink);
color: var(--paper);
rotate: -1.6deg;
scale: 1.02 1;
}
.cth-26__bar--b {
background: var(--acc);
color: var(--paper);
rotate: 1.2deg;
scale: 1.02 1;
margin-top: -4px;
}
.cth-26__track {
display: flex;
width: max-content;
animation: cth26-roll 16s linear infinite;
}
.cth-26__track--rev {
animation: cth26-roll 22s linear infinite reverse;
}
@keyframes cth26-roll {
to {
transform: translateX(-50%);
}
}
.cth-26__group {
font-size: clamp(17px,3cqi,26px);
font-weight: 850;
letter-spacing: .04em;
white-space: nowrap;
padding-right: .5em;
}
.cth-26__hint {
padding: 0 clamp(20px,5cqi,64px);
font-size: 12.5px;
line-height: 1.7;
color: var(--mut);
max-width: 64ch;
}
.cth-26__hint code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .9em;
background: oklch(0.9 0.008 90);
padding: 1px 6px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
.cth-26__track {
animation-play-state: paused;
}
}.cth-26,
.cth-26 *,
.cth-26 *::before,
.cth-26 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cth-26 {
--bg: oklch(0.96 0.005 90);
--ink: oklch(0.17 0.005 90);
--paper: oklch(0.99 0.002 90);
--acc: oklch(0.62 0.21 30);
--mut: oklch(0.48 0.01 90);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
}
.cth-26__stage {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
background: var(--bg);
padding: clamp(20px,4vw,56px) 0;
container: cth26/inline-size;
overflow: clip;
}
.cth-26__scene {
width: 100%;
display: flex;
flex-direction: column;
gap: clamp(22px,4cqi,36px);
}
.cth-26__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 clamp(20px,5cqi,64px);
}
.cth-26__head h1 {
font-size: clamp(30px,6cqi,54px);
font-weight: 900;
letter-spacing: -.04em;
}
.cth-26__pause {
min-height: 46px;
padding: 0 20px;
border-radius: 99px;
border: 2px solid var(--ink);
background: var(--paper);
font-size: 13px;
font-weight: 800;
letter-spacing: .06em;
text-transform: uppercase;
cursor: pointer;
transition: background .25s,color .25s;
}
.cth-26__pause:hover {
background: var(--ink);
color: var(--paper);
}
.cth-26__pause[aria-pressed="true"] {
background: var(--acc);
border-color: var(--acc);
color: var(--paper);
}
.cth-26__pause:focus-visible {
outline: 3px solid var(--acc);
outline-offset: 3px;
}
.cth-26__bar {
overflow: clip;
padding: 14px 0;
cursor: pointer;
}
.cth-26__bar--a {
background: var(--ink);
color: var(--paper);
rotate: -1.6deg;
scale: 1.02 1;
}
.cth-26__bar--b {
background: var(--acc);
color: var(--paper);
rotate: 1.2deg;
scale: 1.02 1;
margin-top: -4px;
}
.cth-26__track {
display: flex;
width: max-content;
animation: cth26-roll 16s linear infinite;
}
.cth-26__track--rev {
animation: cth26-roll 22s linear infinite reverse;
}
@keyframes cth26-roll {
to {
transform: translateX(-50%);
}
}
.cth-26__group {
font-size: clamp(17px,3cqi,26px);
font-weight: 850;
letter-spacing: .04em;
white-space: nowrap;
padding-right: .5em;
}
.cth-26__hint {
padding: 0 clamp(20px,5cqi,64px);
font-size: 12.5px;
line-height: 1.7;
color: var(--mut);
max-width: 64ch;
}
.cth-26__hint code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .9em;
background: oklch(0.9 0.008 90);
padding: 1px 6px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
.cth-26__track {
animation-play-state: paused;
}
}(function(){
var reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
var tracks=[].slice.call(document.querySelectorAll('[data-marquee]'));
var anims=tracks.map(function(t){
var a=(t.getAnimations?t.getAnimations():[]).filter(function(x){return x.animationName==='cth26-roll';})[0];
if(a&&reduced)a.pause();
return a;
}).filter(Boolean);
tracks.forEach(function(t,i){
var bar=t.parentElement,a=anims[i];
if(!a)return;
bar.addEventListener('pointerenter',function(){a.updatePlaybackRate(4);});
bar.addEventListener('pointerleave',function(){a.updatePlaybackRate(1);});
});
var btn=document.querySelector('.cth-26__pause');
if(btn)btn.addEventListener('click',function(){
var pause=btn.getAttribute('aria-pressed')!=='true';
btn.setAttribute('aria-pressed',String(pause));
btn.textContent=pause?'Play tickers':'Pause tickers';
anims.forEach(function(a){pause?a.pause():a.play();});
});
})();(function(){
var reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
var tracks=[].slice.call(document.querySelectorAll('[data-marquee]'));
var anims=tracks.map(function(t){
var a=(t.getAnimations?t.getAnimations():[]).filter(function(x){return x.animationName==='cth26-roll';})[0];
if(a&&reduced)a.pause();
return a;
}).filter(Boolean);
tracks.forEach(function(t,i){
var bar=t.parentElement,a=anims[i];
if(!a)return;
bar.addEventListener('pointerenter',function(){a.updatePlaybackRate(4);});
bar.addEventListener('pointerleave',function(){a.updatePlaybackRate(1);});
});
var btn=document.querySelector('.cth-26__pause');
if(btn)btn.addEventListener('click',function(){
var pause=btn.getAttribute('aria-pressed')!=='true';
btn.setAttribute('aria-pressed',String(pause));
btn.textContent=pause?'Play tickers':'Pause tickers';
anims.forEach(function(a){pause?a.pause():a.play();});
});
})();Here's a working CSS Text Hover Effect from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Text Marquee Speed-Up on Hover
Source: https://codefronts.com/motion/css-text-hover-effects/text-marquee-speed-up-on-hover/
The streetwear ticker with the acceleration everyone gets wrong: the loop is pure CSS (duplicated content, translateX to -50%, seamless forever), and hovering makes it SURGE smoothly — because 16 lines of JS grab the CSS animation through the Web Animations API and call updatePlaybackRate(4), which velocity-matches instead of teleporting. Changing animation-duration in CSS snaps the phase (the naive approach visibly jumps); updatePlaybackRate is the engine-level 'same tape, faster motor'. Includes the legally-required-by-taste pause button, wired to the same API.
## HTML
```html
<section class="cth-26" aria-label="Marquee speed up on hover demo">
<div class="cth-26__stage">
<div class="cth-26__scene">
<header class="cth-26__head">
<h1>ARCHIVE / 004</h1>
<button class="cth-26__pause" type="button" aria-pressed="false">Pause tickers</button>
</header>
<div class="cth-26__bar cth-26__bar--a" id="cth26-top">
<div class="cth-26__track" data-marquee>
<p class="cth-26__group">NEW DROP 004 ◆ FREE SHIPPING OVER $120 ◆ STORE OPENS FRI 10:00 ◆ NO RESTOCKS ◆ </p>
<p class="cth-26__group" aria-hidden="true">NEW DROP 004 ◆ FREE SHIPPING OVER $120 ◆ STORE OPENS FRI 10:00 ◆ NO RESTOCKS ◆ </p>
</div>
</div>
<div class="cth-26__bar cth-26__bar--b">
<div class="cth-26__track cth-26__track--rev" data-marquee>
<p class="cth-26__group">WORLDWIDE ◇ 100% RECYCLED FLEECE ◇ SIZE RUNS BIG ◇ MEMBERS EARLY ACCESS THU ◇ </p>
<p class="cth-26__group" aria-hidden="true">WORLDWIDE ◇ 100% RECYCLED FLEECE ◇ SIZE RUNS BIG ◇ MEMBERS EARLY ACCESS THU ◇ </p>
</div>
</div>
<p class="cth-26__hint">Hover a strip: <code>anim.updatePlaybackRate(4)</code> surges it with zero phase jump — the thing a CSS duration swap can't do. Counter-flow directions are the streetwear tell.</p>
</div>
</div>
</section>
```
## CSS
```css
.cth-26,
.cth-26 *,
.cth-26 *::before,
.cth-26 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cth-26 {
--bg: oklch(0.96 0.005 90);
--ink: oklch(0.17 0.005 90);
--paper: oklch(0.99 0.002 90);
--acc: oklch(0.62 0.21 30);
--mut: oklch(0.48 0.01 90);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
}
.cth-26__stage {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
background: var(--bg);
padding: clamp(20px,4vw,56px) 0;
container: cth26/inline-size;
overflow: clip;
}
.cth-26__scene {
width: 100%;
display: flex;
flex-direction: column;
gap: clamp(22px,4cqi,36px);
}
.cth-26__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 clamp(20px,5cqi,64px);
}
.cth-26__head h1 {
font-size: clamp(30px,6cqi,54px);
font-weight: 900;
letter-spacing: -.04em;
}
.cth-26__pause {
min-height: 46px;
padding: 0 20px;
border-radius: 99px;
border: 2px solid var(--ink);
background: var(--paper);
font-size: 13px;
font-weight: 800;
letter-spacing: .06em;
text-transform: uppercase;
cursor: pointer;
transition: background .25s,color .25s;
}
.cth-26__pause:hover {
background: var(--ink);
color: var(--paper);
}
.cth-26__pause[aria-pressed="true"] {
background: var(--acc);
border-color: var(--acc);
color: var(--paper);
}
.cth-26__pause:focus-visible {
outline: 3px solid var(--acc);
outline-offset: 3px;
}
.cth-26__bar {
overflow: clip;
padding: 14px 0;
cursor: pointer;
}
.cth-26__bar--a {
background: var(--ink);
color: var(--paper);
rotate: -1.6deg;
scale: 1.02 1;
}
.cth-26__bar--b {
background: var(--acc);
color: var(--paper);
rotate: 1.2deg;
scale: 1.02 1;
margin-top: -4px;
}
.cth-26__track {
display: flex;
width: max-content;
animation: cth26-roll 16s linear infinite;
}
.cth-26__track--rev {
animation: cth26-roll 22s linear infinite reverse;
}
@keyframes cth26-roll {
to {
transform: translateX(-50%);
}
}
.cth-26__group {
font-size: clamp(17px,3cqi,26px);
font-weight: 850;
letter-spacing: .04em;
white-space: nowrap;
padding-right: .5em;
}
.cth-26__hint {
padding: 0 clamp(20px,5cqi,64px);
font-size: 12.5px;
line-height: 1.7;
color: var(--mut);
max-width: 64ch;
}
.cth-26__hint code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .9em;
background: oklch(0.9 0.008 90);
padding: 1px 6px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
.cth-26__track {
animation-play-state: paused;
}
}
```
## JavaScript
```js
(function(){
var reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
var tracks=[].slice.call(document.querySelectorAll('[data-marquee]'));
var anims=tracks.map(function(t){
var a=(t.getAnimations?t.getAnimations():[]).filter(function(x){return x.animationName==='cth26-roll';})[0];
if(a&&reduced)a.pause();
return a;
}).filter(Boolean);
tracks.forEach(function(t,i){
var bar=t.parentElement,a=anims[i];
if(!a)return;
bar.addEventListener('pointerenter',function(){a.updatePlaybackRate(4);});
bar.addEventListener('pointerleave',function(){a.updatePlaybackRate(1);});
});
var btn=document.querySelector('.cth-26__pause');
if(btn)btn.addEventListener('click',function(){
var pause=btn.getAttribute('aria-pressed')!=='true';
btn.setAttribute('aria-pressed',String(pause));
btn.textContent=pause?'Play tickers':'Pause tickers';
anims.forEach(function(a){pause?a.pause():a.play();});
});
})();
```Here's a working CSS Text Hover Effect from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Text Marquee Speed-Up on Hover
Source: https://codefronts.com/motion/css-text-hover-effects/text-marquee-speed-up-on-hover/
The streetwear ticker with the acceleration everyone gets wrong: the loop is pure CSS (duplicated content, translateX to -50%, seamless forever), and hovering makes it SURGE smoothly — because 16 lines of JS grab the CSS animation through the Web Animations API and call updatePlaybackRate(4), which velocity-matches instead of teleporting. Changing animation-duration in CSS snaps the phase (the naive approach visibly jumps); updatePlaybackRate is the engine-level 'same tape, faster motor'. Includes the legally-required-by-taste pause button, wired to the same API.
## HTML
```html
<section class="cth-26" aria-label="Marquee speed up on hover demo">
<div class="cth-26__stage">
<div class="cth-26__scene">
<header class="cth-26__head">
<h1>ARCHIVE / 004</h1>
<button class="cth-26__pause" type="button" aria-pressed="false">Pause tickers</button>
</header>
<div class="cth-26__bar cth-26__bar--a" id="cth26-top">
<div class="cth-26__track" data-marquee>
<p class="cth-26__group">NEW DROP 004 ◆ FREE SHIPPING OVER $120 ◆ STORE OPENS FRI 10:00 ◆ NO RESTOCKS ◆ </p>
<p class="cth-26__group" aria-hidden="true">NEW DROP 004 ◆ FREE SHIPPING OVER $120 ◆ STORE OPENS FRI 10:00 ◆ NO RESTOCKS ◆ </p>
</div>
</div>
<div class="cth-26__bar cth-26__bar--b">
<div class="cth-26__track cth-26__track--rev" data-marquee>
<p class="cth-26__group">WORLDWIDE ◇ 100% RECYCLED FLEECE ◇ SIZE RUNS BIG ◇ MEMBERS EARLY ACCESS THU ◇ </p>
<p class="cth-26__group" aria-hidden="true">WORLDWIDE ◇ 100% RECYCLED FLEECE ◇ SIZE RUNS BIG ◇ MEMBERS EARLY ACCESS THU ◇ </p>
</div>
</div>
<p class="cth-26__hint">Hover a strip: <code>anim.updatePlaybackRate(4)</code> surges it with zero phase jump — the thing a CSS duration swap can't do. Counter-flow directions are the streetwear tell.</p>
</div>
</div>
</section>
```
## CSS
```css
.cth-26,
.cth-26 *,
.cth-26 *::before,
.cth-26 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.cth-26 {
--bg: oklch(0.96 0.005 90);
--ink: oklch(0.17 0.005 90);
--paper: oklch(0.99 0.002 90);
--acc: oklch(0.62 0.21 30);
--mut: oklch(0.48 0.01 90);
font-family: 'Segoe UI',system-ui,sans-serif;
color: var(--ink);
}
.cth-26__stage {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
background: var(--bg);
padding: clamp(20px,4vw,56px) 0;
container: cth26/inline-size;
overflow: clip;
}
.cth-26__scene {
width: 100%;
display: flex;
flex-direction: column;
gap: clamp(22px,4cqi,36px);
}
.cth-26__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 clamp(20px,5cqi,64px);
}
.cth-26__head h1 {
font-size: clamp(30px,6cqi,54px);
font-weight: 900;
letter-spacing: -.04em;
}
.cth-26__pause {
min-height: 46px;
padding: 0 20px;
border-radius: 99px;
border: 2px solid var(--ink);
background: var(--paper);
font-size: 13px;
font-weight: 800;
letter-spacing: .06em;
text-transform: uppercase;
cursor: pointer;
transition: background .25s,color .25s;
}
.cth-26__pause:hover {
background: var(--ink);
color: var(--paper);
}
.cth-26__pause[aria-pressed="true"] {
background: var(--acc);
border-color: var(--acc);
color: var(--paper);
}
.cth-26__pause:focus-visible {
outline: 3px solid var(--acc);
outline-offset: 3px;
}
.cth-26__bar {
overflow: clip;
padding: 14px 0;
cursor: pointer;
}
.cth-26__bar--a {
background: var(--ink);
color: var(--paper);
rotate: -1.6deg;
scale: 1.02 1;
}
.cth-26__bar--b {
background: var(--acc);
color: var(--paper);
rotate: 1.2deg;
scale: 1.02 1;
margin-top: -4px;
}
.cth-26__track {
display: flex;
width: max-content;
animation: cth26-roll 16s linear infinite;
}
.cth-26__track--rev {
animation: cth26-roll 22s linear infinite reverse;
}
@keyframes cth26-roll {
to {
transform: translateX(-50%);
}
}
.cth-26__group {
font-size: clamp(17px,3cqi,26px);
font-weight: 850;
letter-spacing: .04em;
white-space: nowrap;
padding-right: .5em;
}
.cth-26__hint {
padding: 0 clamp(20px,5cqi,64px);
font-size: 12.5px;
line-height: 1.7;
color: var(--mut);
max-width: 64ch;
}
.cth-26__hint code {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: .9em;
background: oklch(0.9 0.008 90);
padding: 1px 6px;
border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
.cth-26__track {
animation-play-state: paused;
}
}
```
## JavaScript
```js
(function(){
var reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
var tracks=[].slice.call(document.querySelectorAll('[data-marquee]'));
var anims=tracks.map(function(t){
var a=(t.getAnimations?t.getAnimations():[]).filter(function(x){return x.animationName==='cth26-roll';})[0];
if(a&&reduced)a.pause();
return a;
}).filter(Boolean);
tracks.forEach(function(t,i){
var bar=t.parentElement,a=anims[i];
if(!a)return;
bar.addEventListener('pointerenter',function(){a.updatePlaybackRate(4);});
bar.addEventListener('pointerleave',function(){a.updatePlaybackRate(1);});
});
var btn=document.querySelector('.cth-26__pause');
if(btn)btn.addEventListener('click',function(){
var pause=btn.getAttribute('aria-pressed')!=='true';
btn.setAttribute('aria-pressed',String(pause));
btn.textContent=pause?'Play tickers':'Pause tickers';
anims.forEach(function(a){pause?a.pause():a.play();});
});
})();
```How this works
The loop first — geometry makes it seamless, not luck. The track holds the phrase TWICE and travels exactly one copy's width:
.track{ display:flex; width:max-content;
animation:cth26-roll 16s linear infinite; }
@keyframes cth26-roll{ to{ transform:translateX(-50%); } }
/* -50% of a track containing 2 identical groups
= exactly one group = a perfect splice */Then the acceleration. The CSS animation is reachable FROM JavaScript as a real animation object, and playback rate is the one knob that changes speed without touching phase:
const anim = track.getAnimations()[0];
bar.addEventListener('pointerenter',
() => anim.updatePlaybackRate(4));
bar.addEventListener('pointerleave',
() => anim.updatePlaybackRate(1));Why not just toggle a class with a shorter animation-duration? Because duration maps elapsed-time→progress: change 16s to 4s mid-flight and the same elapsed time suddenly means a different progress % — the strip TELEPORTS. updatePlaybackRate() is specified to preserve current time while changing velocity (it even synchronizes asynchronously to avoid a seek), so the surge is perfectly continuous. It's the same API the pause button uses (anim.pause()/play()) — one animation object, three controls, zero re-implementation of the loop in JS.
The two strips run opposite directions at different base speeds (16s and 22s) — counter-flow is what makes a drop-bar feel like weather instead of a screensaver. Type is set once per group; content duplication is aria-hidden so screen readers hear the phrase exactly once.
Make it yours
- Surge ratio: 4× reads as a rev; 1.6× as leaning in. You can also updatePlaybackRate(0.3) on hover for the luxury inverse — the strip slows so you can read it (swap one number).
- Ease the surge itself: wrap the rate change in a tiny JS lerp (rate += (target-rate)*0.15 per frame) for a turbine spool-up; the API accepts fractional rates continuously.
- Content is the design: repeat the phrase enough times that one GROUP is wider than any viewport you support (or the -50% splice exposes a gap on ultrawide monitors).
- Vertical ticker: same math with translateY on a column track — testimonial rails and news feeds use the identical two-copy contract.
Gotchas — read before shipping
- The seamless contract is exactly two identical groups + translateX(-50%). Three copies with -50%, or padding between groups not mirrored at the seam, and the splice pops once per loop.
- getAnimations() returns animations in effect ORDER — if the track ever gains a second animation (a fade-in!), [0] may be the wrong one; find it by animationName via anim.animationName check (the demo does).
- width:max-content on the track is required; without it the track wraps to the bar's width and -50% stops equaling one group.
- An unpausable ticker is a WCAG 2.2.2 failure (moving content over 5s needs a control) — the pause button isn't decor; keep it, and prefers-reduced-motion should park the whole thing (this demo starts paused there).
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 111+ | 16.4+ | 113+ | 111+ |
CSS marquee runs anywhere (2015-era). getAnimations()/updatePlaybackRate: Chrome 84+/Safari 13.1+/Firefox 75+. Without JS the strip still loops at base speed — the surge and pause are enhancements.