Large typography is everywhere in contemporary web design. That part is no longer particularly interesting on its own.
What I find more useful is what happens when typography stops behaving like a predefined animation and starts responding to how the user interacts with the page.
Instead of saying:
the page is at 1,200px, therefore move this text by 300px
we can say:
the user just accelerated the scroll, therefore push the typography harder in that direction.
That small change produces a completely different feeling.
I have been looking at motion-heavy portfolio and studio sites and experimenting with ways to reproduce some of that energy without automatically reaching for GSAP, a smooth-scrolling library, or a larger animation stack.
The result is a small interaction primitive I call scroll velocity typography.
When you scroll slowly, the text barely moves. Flick the page quickly and the rows separate. Reverse the scroll direction and the typography follows. Stop interacting and the movement loses momentum naturally.
The implementation is just HTML, CSS, and vanilla JavaScript.
The Interaction I Wanted #
The visual reference point is familiar: oversized repeated words, aggressive editorial typography, outlined text, and horizontal movement.
But I did not want another infinite marquee.
I also did not want the position to be directly mapped to the document scroll position.
A typical scroll-linked implementation might start with something like:
const offset = window.scrollY * 0.5;
That works, but the result feels like conventional parallax. The relationship between page position and element position is fixed.
For this experiment, the important value is not the scroll position.
It is the change in scroll position:
const delta = currentScrollY - previousScrollY;
That delta gives us a simple approximation of scroll velocity between events.
The user is no longer just revealing an animation by moving through the document. Their input effectively pushes the typography.
Live Demo #
I normally put the interactive demo immediately before the implementation so the reader can understand the behavior before reading the code.
For a published CodePen, the embed can sit here:
I would publish the same HTML, CSS, and JavaScript below as the Pen rather than creating a simplified demo. That way the article and interactive example remain synchronized.
HTML Structure #
The markup is deliberately boring.
That is a feature.
<section class="kinetic" dir="rtl">
<div class="kinetic__row" data-speed="0.75">
<div class="kinetic__track">
<span>אנחנו יוצרים</span>
<span class="kinetic__outline">אתרים שעובדים</span>
<span>אנחנו יוצרים</span>
<span class="kinetic__outline">אתרים שעובדים</span>
</div>
</div>
<div
class="kinetic__row kinetic__row--reverse"
data-speed="1"
>
<div class="kinetic__track">
<span class="kinetic__outline">מהירים</span>
<span>חכמים</span>
<span class="kinetic__outline">מדויקים</span>
<span>מהירים</span>
<span>חכמים</span>
<span class="kinetic__outline">מדויקים</span>
</div>
</div>
<div class="kinetic__row" data-speed="1.3">
<div class="kinetic__track">
<span>Design</span>
<span class="kinetic__dot">●</span>
<span>Development</span>
<span class="kinetic__dot">●</span>
<span>Performance</span>
<span class="kinetic__dot">●</span>
<span>Design</span>
<span class="kinetic__dot">●</span>
<span>Development</span>
</div>
</div>
</section>
Each row has a data-speed attribute. That lets me vary the response without hardcoding individual rows in JavaScript.
One row also gets:
kinetic__row--reverse
so adjacent lines can react in opposite directions.
There is no animation-specific markup beyond those two controls.
CSS #
Here is the visual layer:
.kinetic {
--kinetic-bg: #111;
--kinetic-color: #f4f1e8;
--kinetic-accent: #d9ff43;
position: relative;
overflow: hidden;
padding: clamp(50px, 8vw, 110px) 0;
background: var(--kinetic-bg);
color: var(--kinetic-color);
contain: layout paint;
}
.kinetic__row {
position: relative;
width: 100%;
overflow: hidden;
}
.kinetic__row + .kinetic__row {
margin-top: clamp(2px, 0.5vw, 8px);
}
.kinetic__track {
--move: 0px;
display: flex;
align-items: center;
gap: clamp(22px, 3vw, 60px);
width: max-content;
padding-inline: 3vw;
font-family: Arial, Helvetica, sans-serif;
font-size: clamp(54px, 9vw, 150px);
font-weight: 700;
line-height: 0.88;
letter-spacing: -0.055em;
white-space: nowrap;
transform: translate3d(var(--move), 0, 0);
will-change: transform;
}
.kinetic__row--reverse .kinetic__track {
flex-direction: row-reverse;
}
.kinetic__outline {
color: transparent;
-webkit-text-stroke: 1.5px currentColor;
-webkit-text-stroke-color: var(--kinetic-color);
}
.kinetic__dot {
color: var(--kinetic-accent);
font-size: 0.42em;
letter-spacing: 0;
}
@media (max-width: 767px) {
.kinetic {
padding: 55px 0;
}
.kinetic__track {
gap: 22px;
font-size: clamp(46px, 16vw, 76px);
}
}
@media (prefers-reduced-motion: reduce) {
.kinetic__track {
transform: none !important;
will-change: auto;
}
}
The important part is this:
transform: translate3d(var(--move), 0, 0);
The JavaScript does not continuously manipulate margins, left, width, or another geometry-related property.
Current browser performance guidance still makes transforms the obvious starting point for this kind of movement. Transform and opacity animations can often avoid the expensive layout work associated with changing page geometry.
That does not mean transform makes an animation magically free. JavaScript execution, painting, compositing, layer memory, and the rest of the page still matter.
But it gives us a much better foundation than moving the same element with left.
Vanilla JavaScript #
Here is the complete interaction:
<script>
(() => {
const section = document.querySelector('.kinetic');
if (
!section ||
window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches
) {
return;
}
const rows = [
...section.querySelectorAll('.kinetic__row')
];
let previousScrollY = window.scrollY;
let velocity = 0;
let targetVelocity = 0;
let position = 0;
let active = false;
let ticking = false;
const observer = new IntersectionObserver(
entries => {
active = entries[0].isIntersecting;
if (active) {
requestTick();
}
},
{
rootMargin: '150px 0px 150px 0px'
}
);
observer.observe(section);
const handleScroll = () => {
const currentScrollY = window.scrollY;
const delta = currentScrollY - previousScrollY;
previousScrollY = currentScrollY;
targetVelocity = Math.max(
-45,
Math.min(45, delta)
);
requestTick();
};
function requestTick() {
if (ticking) {
return;
}
ticking = true;
requestAnimationFrame(update);
}
function update() {
ticking = false;
if (!active) {
return;
}
velocity += (
targetVelocity - velocity
) * 0.12;
targetVelocity *= 0.82;
position += velocity * 0.55;
rows.forEach(row => {
const track = row.querySelector(
'.kinetic__track'
);
const speed = parseFloat(
row.dataset.speed || 1
);
const direction =
row.classList.contains(
'kinetic__row--reverse'
)
? -1
: 1;
const offset =
position * speed * direction;
track.style.setProperty(
'--move',
`${offset}px`
);
});
if (
Math.abs(velocity) > 0.02 ||
Math.abs(targetVelocity) > 0.02
) {
requestTick();
}
}
window.addEventListener(
'scroll',
handleScroll,
{ passive: true }
);
})();
</script>
No jQuery.
No GSAP.
No Lenis.
No ScrollMagic.
No Three.js.
None of those libraries are inherently a problem. GSAP in particular makes sense when an interface has timelines, sequencing, complex easing, pinning, SVG work, or a larger animation system.
I just do not need any of that here.
For one small interaction, browser APIs are enough.
Why Scroll Velocity Typography Feels Different #
Consider two users who both arrive at exactly the same vertical position.
With conventional position-based parallax:
offset = window.scrollY * 0.5;
both users get approximately the same visual state.
With the velocity approach, they may see different motion.
Someone slowly reading the page produces a tiny delta:
delta: 2
delta: 3
delta: 2
The typography stays relatively calm.
Someone quickly flicking through the page might produce:
delta: 24
delta: 38
delta: 31
The text receives a much stronger push.
Scroll upward and the delta becomes negative.
That reverses the movement automatically.
This is what interests me about the technique. Motion becomes a response to the character of the interaction, not merely a visualization of the page coordinate.
Why I Avoided an Infinite requestAnimationFrame Loop #
A simple animation implementation often looks like this:
function animate() {
updateEverything();
requestAnimationFrame(animate);
}
animate();
That can be completely appropriate for continuously animated scenes.
But this interaction does not need to run continuously.
requestAnimationFrame() is a one-shot request: if another animation frame is needed, the callback schedules another one.
So I use that behavior instead.
Scrolling calls:
requestTick();
and requestTick() prevents duplicate frame requests:
function requestTick() {
if (ticking) return;
ticking = true;
requestAnimationFrame(update);
}
The loop continues only while velocity is still visually relevant:
if (
Math.abs(velocity) > 0.02 ||
Math.abs(targetVelocity) > 0.02
) {
requestTick();
}
Once the movement has decayed sufficiently, no new frame is requested.
The animation goes idle.
IntersectionObserver Gives Me Another Boundary #
There is no reason to keep updating this effect when the entire kinetic section is far away from the viewport.
That is where IntersectionObserver fits nicely.
const observer = new IntersectionObserver(
entries => {
active = entries[0].isIntersecting;
if (active) {
requestTick();
}
},
{
rootMargin: '150px 0px 150px 0px'
}
);
I intentionally give it a 150px margin so the effect can become active shortly before the section actually enters the viewport.
This is less about chasing a theoretical performance score and more about giving the component a sensible lifecycle.
Visible or nearly visible?
It can work.
Far away?
Leave it alone.
Why Transform Instead of Left #
I specifically avoided this:
element.style.left = offset + 'px';
Changing layout-related properties can require the browser to recalculate geometry and potentially trigger additional rendering work.
Instead, I update a custom property:
track.style.setProperty(
'--move',
`${offset}px`
);
which feeds a transform:
transform: translate3d(var(--move), 0, 0);
The exact rendering behavior always depends on the browser and the rest of the page, so I would not describe this as “GPU accelerated therefore free.”
It is simply the more appropriate property for this type of visual movement.
I would still profile the finished page in DevTools rather than assuming an isolated demo tells me how the production site performs.
Reduced Motion Is Part of the Component #
Motion-heavy experiments need an exit.
The CSS version is straightforward:
@media (prefers-reduced-motion: reduce) {
.kinetic__track {
transform: none !important;
will-change: auto;
}
}
I also check the same preference before initializing JavaScript:
if (
window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches
) {
return;
}
So users who request reduced motion do not simply get a slower version of the effect.
They get no velocity animation at all.
For this component, that is the correct fallback because the animation is decorative. The text remains available and readable without it.
Where I Would Actually Use This #
I would probably not make this the main interaction in a hero.
A hero already has an important job: explain what the site or product is and give the visitor a useful next action.
I prefer velocity typography as punctuation between sections.
For example:
HERO
Services
Services
Services
────────────────────────
KINETIC TYPOGRAPHY
Think. Design. Build.
────────────────────────
Selected work
Selected work
Selected work
It works particularly well when surrounded by calmer sections.
That contrast matters.
If every heading moves, every image skews, every card has inertia and the cursor is being chased by a blob, none of those interactions feels particularly special anymore.
I would consider this treatment for:
- creative studios;
- architecture;
- property and real-estate projects;
- furniture;
- fashion;
- product launches;
- technology landing pages;
- portfolio sites;
- selected e-commerce categories.
I would be much more conservative with it on interfaces where authority, predictability, or task completion dominates the experience.
Motion needs a reason to exist.
WordPress + ACF #
The same component can be made editable without exposing the entire animation system to the CMS.
My ACF repeater would probably contain something like:
kinetic_rows
text
speed
reverse
Then:
<?php if ( have_rows( 'kinetic_rows' ) ) : ?>
<section class="kinetic" dir="rtl">
<?php
while ( have_rows( 'kinetic_rows' ) ) :
the_row();
$text = get_sub_field( 'text' );
$speed =
get_sub_field( 'speed' ) ?: 1;
$reverse =
get_sub_field( 'reverse' );
?>
<div
class="kinetic__row<?php
echo $reverse
? ' kinetic__row--reverse'
: '';
?>"
data-speed="<?php
echo esc_attr( $speed );
?>"
>
<div class="kinetic__track">
<?php for (
$i = 0;
$i < 3;
$i++
) : ?>
<span>
<?php
echo esc_html( $text );
?>
</span>
<?php endfor; ?>
</div>
</div>
<?php endwhile; ?>
</section>
<?php endif; ?>
I deliberately would not expose damping, friction, transforms, arbitrary colors, easing values, or ten other animation parameters to a content manager.
A CMS should control content.
It does not necessarily need to become an animation editor.
Text, direction and perhaps a constrained speed option are enough.
Otherwise somebody eventually discovers that speed="9000" is technically valid, combines it with bright green Comic Sans, and the component achieves its final form.
Taking the Primitive Further #
The typography itself is not really the most reusable part of this experiment.
The useful primitive is:
user input
↓
scroll delta
↓
velocity
↓
smoothing
↓
friction
↓
visual property
Once I have a normalized velocity value, horizontal text translation is only one possible output.
The same signal could drive:
Image skew #
Fast downward movement could temporarily skew project thumbnails and return them to zero when scrolling stops.
Horizontal galleries #
Velocity could add momentum to an otherwise horizontal project rail.
Image masks #
A mask could stretch slightly according to scroll direction and speed.
Variable fonts #
If the selected variable font exposes a useful axis, velocity could temporarily modify weight or width.
CTA arrows #
A directional arrow could react to acceleration without turning the entire component into a scroll animation.
Card stacks #
Cards could separate slightly under fast input and settle back as velocity decays.
That is the broader pattern I want to keep.
Not:
I made some text slide around.
But:
I have a small input-to-motion layer that can drive different presentation components.
That makes the experiment much more useful in an actual front-end system.
Frequently Asked Questions #
What is scroll velocity typography? #
Scroll velocity typography is text animation driven by changes in the user’s scrolling speed and direction rather than only by the page’s absolute scroll position.
A quick scroll can produce stronger movement, a slow scroll can produce almost none, and reversing scroll direction can reverse the animation.
Do I need GSAP for scroll velocity animation? #
No. Simple scroll velocity effects can be implemented with standard browser APIs and CSS transforms.
A dedicated animation library becomes more useful when the project needs complex timelines, synchronization, pinning, sophisticated sequencing, or a broader animation architecture.
Why use requestAnimationFrame for scroll animation? #
requestAnimationFrame() lets JavaScript request work before a browser repaint, making it appropriate for visual updates.
For this implementation, I also use its one-shot nature to avoid maintaining an animation loop when there is no meaningful movement left to render.
Is transform better than left for animation? #
For movement effects, transform is generally a better starting point because changing geometry-related properties such as left can involve layout work.
That does not guarantee perfect performance. The complete page should still be profiled because JavaScript, paint complexity, compositing, images and other components can all affect rendering.
Why use IntersectionObserver here? #
IntersectionObserver gives the component a simple activity boundary. The velocity animation only needs to update when its section is visible or close to becoming visible.
It also avoids repeatedly calculating the section’s viewport position manually on every scroll event.
Does scroll velocity typography work on mobile? #
The general approach works with normal document scrolling on modern mobile browsers, but mobile testing is essential.
Touch scrolling can produce substantial momentum, so clamping extreme delta values is useful. Motion should also be tested on less powerful devices rather than only on a development laptop.
How do I support prefers-reduced-motion? #
Use the prefers-reduced-motion: reduce media query to remove the visual transform and check the same preference in JavaScript before initializing the interaction.
For a decorative effect like this one, disabling the animation completely is a clean fallback.
Can I use scroll velocity for elements other than text? #
Yes. The velocity value is independent of typography.
It can drive transforms, image skew, masks, gallery movement, arrows, variable-font axes, or other visual parameters. I prefer treating velocity as a reusable input signal rather than building it specifically around one text effect.
Conclusion #
The oversized typography is not the important part of this experiment.
The part I am keeping is the interaction model.
Most simple scroll effects ask:
Where is the page?
This one asks:
What is the user doing right now?
That distinction is small in code but surprisingly noticeable in the interface.
By deriving motion from scroll delta, smoothing the resulting velocity, adding friction, updating with requestAnimationFrame(), limiting work with IntersectionObserver, using transforms for movement, and respecting reduced-motion preferences, I can get a responsive kinetic effect without adding an animation framework.
More importantly, the same mechanism can be reused elsewhere.
Typography is just the first output.
The real primitive is velocity → response.
And that is much more interesting than another marquee.