On this tutorial, you’ll construct three scroll-driven textual content results utilizing solely CSS, JavaScript, and GSAP. As a substitute of
counting on a 3D library, you’ll mix CSS transforms with GSAP’s ScrollTrigger plugin to hyperlink movement on to
scroll place, creating clean, high-performance 3D animations.
Preliminary Setup
Step one is to initialize the venture and arrange its construction. Nothing fancy—only a easy, organized setup to
hold issues clear and straightforward to observe.
We’ll use a class-based mannequin, beginning with an
App
class as our major entry level and three separate courses for every animation. The ultimate venture will appear to be this:

On the coronary heart of this setup is GSAP. We’ll register its ScrollTrigger and ScrollSmoother plugins, which deal with clean
scrolling and scroll-based animations all through all three results. ScrollSmoother ensures constant, GPU-accelerated
scrolling, whereas ScrollTrigger ties our animations on to scroll progress — the 2 plugins work collectively to maintain
the movement completely synced and stutter-free.
The principle entry level would be the
major.ts
file, which at present seems like this:
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { ScrollSmoother } from "gsap/ScrollSmoother";
class App {
smoother!: ScrollSmoother;
constructor() {
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);
this.init();
this.addEventListeners();
}
init(): void {
this.setupScrollSmoother();
}
// optionally available
setupScrollSmoother(): void {
this.smoother = ScrollSmoother.create({
clean: 1,
results: true,
});
}
addEventListeners(): void {
window.addEventListener("resize", () => {
console.log("resize");
});
}
}
new App();
Our examples use the TypeScript syntax (for kind security and higher editor help), however you’ll be able to write it identically in plain JavaScript. Merely take away the kind annotations (like : void or !:) and it’ll work the identical approach.
Creating Our First Impact: Cylinder
For our first impact, we’ll place textual content round an invisible cylinder that reveals itself as you scroll—with out
counting on a 3D library like Three.js. You possibly can see comparable examples on
BPCO
and
Sturdy
, so shout-out to these creators for the inspiration.
Constructing the Construction with HTML & CSS
<part class="cylinder__wrapper">
<p class="cylinder__title">hold scrolling to see the animation</p>
<ul class="cylinder__text__wrapper">
<li class="cylinder__text__item">design</li>
<li class="cylinder__text__item">growth</li>
<li class="cylinder__text__item">branding</li>
<li class="cylinder__text__item">advertising and marketing</li>
<li class="cylinder__text__item">copywriting</li>
<li class="cylinder__text__item">content material</li>
<li class="cylinder__text__item">illustration</li>
<li class="cylinder__text__item">video</li>
<li class="cylinder__text__item">images</li>
<li class="cylinder__text__item">3d graphic</li>
<li class="cylinder__text__item">scroll</li>
<li class="cylinder__text__item">animation</li>
</ul>
</part>
.cylinder__wrapper {
width: 100%;
peak: 100svh;
place: relative;
perspective: 70vw;
overflow: hidden;
@media display and (max-width: 768px) {
perspective: 400px;
}
show: flex;
flex-direction: column;
justify-content: heart;
align-items: heart;
hole: 10rem;
}
.cylinder__text__wrapper {
place: absolute;
font-size: 5vw;
line-height: 5vw;
width: 100%;
peak: 100%;
transform-style: preserve-3d;
transform-origin: heart heart;
font-weight: 600;
text-align: heart;
@media display and (min-width: 2560px) {
font-size: 132px;
line-height: 132px;
}
@media display and (max-width: 768px) {
font-size: 1.6rem;
line-height: 1.6rem;
}
}
.cylinder__text__item {
place: absolute;
left: 50;
prime: 50%;
width: 100%;
backface-visibility: hidden;
}
The important thing property that provides our structure a 3D look is perspective: 70vw on the .cylinder__wrapper , which provides depth to the whole impact.
The
transform-style: preserve-3d
property permits us to place youngster parts in 3D house utilizing CSS.
We’ll additionally use the
backface-visibility
property—however extra on that later.
For now, we must always have one thing that appears like this:

We’re not fairly there but—the textual content is at present collapsing on prime of itself as a result of each merchandise shares the identical
place, and the whole lot is aligned to the suitable aspect of the viewport. We’ll deal with all of this with JavaScript. Whereas
we may outline positions instantly in CSS, we wish the impact to work seamlessly throughout all display sizes, so we’ll
calculate positions dynamically as a substitute.
Bringing It to Life with JavaScript
We’ll create a brand new folder named
cylinder
for this impact and outline our
Cylinder
class.
First, we have to initialize the DOM parts required for this animation:
-
Wrapper:
Retains the consumer centered on this part whereas scrolling. -
Textual content gadgets:
Every phrase or phrase positioned round an invisible cylinder. -
Textual content wrapper:
Rotates to create the 3D cylindrical impact. -
Title:
Triggers the animation when it enters the viewport.
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export class Cylinder {
title: HTMLElement;
textWrapper: HTMLElement;
textItems: NodeListOf<HTMLElement>;
wrapper: HTMLElement;
constructor() {
this.title = doc.querySelector('.cylinder__title') as HTMLElement;
this.textWrapper = doc.querySelector('.cylinder__text__wrapper') as HTMLElement;
this.textItems = doc.querySelectorAll('.cylinder__text__item') as NodeListOf<HTMLElement>;
this.wrapper = doc.querySelector('.cylinder__wrapper') as HTMLElement;
this.init();
}
init() {
console.log("init cylinder");
}
}
As soon as we’ve got all the mandatory DOM parts, we are able to initialize our
Cylinder
class within the
major.ts
file. From there, we’re able to place the textual content in 3D house by making a
calculatePositions()
perform, which seems like this:
calculatePositions(): void {
const offset = 0.4;
const radius = Math.min(window.innerWidth, window.innerHeight) * offset;
const spacing = 180 / this.textItems.size;
this.textItems.forEach((merchandise, index) => {
const angle = (index * spacing * Math.PI) / 180;
const rotationAngle = index * -spacing;
const x = 0;
const y = Math.sin(angle) * radius;
const z = Math.cos(angle) * radius;
merchandise.fashion.rework = `translate3d(-50%, -50%, 0) translate3d(${x}px, ${y}px, ${z}px) rotateX(${rotationAngle}deg)`;
});
}
This offers us the next end result:

It’s trying higher — we are able to begin to see the ultimate impact taking form. However earlier than transferring on, let’s go over what’s
really taking place.
Understanding the Place Calculations
The
calculatePositions()
methodology is the place the magic occurs. Let’s break down how we prepare the textual content gadgets round an invisible cylinder.
Defining the Cylinder Dimensions
First, we outline an
offset
worth of
0.4
(be at liberty to experiment with this!). This controls how “tight” or “large” the cylinder seems. We then calculate the
cylinder’s
radius
by multiplying the smaller of the viewport’s width or peak by the
offset
worth. This method ensures the impact scales easily throughout all display sizes.
const offset = 0.4;
const radius = Math.min(window.innerWidth, window.innerHeight) * offset;
Evenly Distributing the Textual content Objects
Subsequent, we decide the spacing between every textual content merchandise by dividing
180
levels by the entire variety of gadgets. This ensures that the textual content parts are evenly distributed alongside the seen
half of the cylinder.
const spacing = 180 / this.textItems.size;
Calculating Every Merchandise’s 3D Place
Subsequent, we calculate every textual content merchandise’s place in 3D house utilizing a little bit of trigonometry. By figuring out the
x
,
y
, and
z
coordinates, we are able to place each merchandise evenly alongside the cylinder’s floor:
-
x
stays
0
— this retains every merchandise horizontally centered. -
y
(vertical place) is calculated utilizing
Math.sin()
to create the curved structure. -
z
(depth) is decided utilizing
Math.cos()
, pushing gadgets ahead or backward to kind the 3D form.
const angle = (index * spacing * Math.PI) / 180;
const rotationAngle = index * -spacing; const x = 0;
const y = Math.sin(angle) * radius;
const z = Math.cos(angle) * radius;
Lastly, we apply these calculations utilizing CSS 3D transforms, positioning and rotating every merchandise to kind the
cylindrical structure.
angle
: Converts every merchandise’s index and spacing into radians (the unit utilized by JavaScript’s Math features).
rotationAngle
: Defines how a lot every merchandise ought to rotate in order that it faces outward from the cylinder.
Bringing the Cylinder to Life with ScrollTrigger
Now that we’ve positioned our textual content, we’ll create a brand new perform known as
createScrollTrigger()
to attach the animation to the consumer’s scroll place and convey the cylinder to life.
Setting Up ScrollTrigger
That is the place GSAP’s ScrollTrigger plugin actually shines — it lets us hyperlink the 3D rotation of our textual content cylinder
on to scroll progress. With out GSAP, synchronizing this type of 3D movement to scroll place would require quite a bit
of guide math and occasion dealing with.
We’ll use GSAP’s
ScrollTrigger.create()
methodology to outline when and the way the animation ought to behave:
ScrollTrigger.create({
set off: this.title,
begin: "heart heart",
finish: "+=2000svh",
pin: this.wrapper,
scrub: 2,
animation: gsap.fromTo(
this.textWrapper,
{ rotateX: -80 },
{ rotateX: 270, ease: "none" }
),
});
GSAP handles the whole timing and rendering of the rotation internally. Because of ScrollTrigger, the animation stays
completely in sync with scrolling and performs easily throughout units.
Underneath the hood,
ScrollTrigger
repeatedly maps scroll distance to the animation’s progress worth (0–1). Meaning you don’t must manually
calculate scroll offsets or deal with momentum — GSAP does the pixel-to-progress conversion and updates transforms in
sync with the browser’s repaint cycle.
And with that, we lastly have our end result:
Breaking Down the Configuration
-
set off:
The ingredient that prompts the animation (on this case, the title ingredient). -
begin:
"heart heart"
means the animation begins when the set off’s heart reaches the middle of the viewport. You possibly can regulate this to
fine-tune when the animation begins. -
finish:
"+=2000svh"
extends the animation length to 2000% of the viewport peak, creating an extended, clean scroll expertise. Modify
this worth to hurry up or decelerate the rotation. -
pin:
Retains the wrapper ingredient fastened in place whereas the animation performs, stopping it from scrolling away. -
scrub:
Set to
2
, this provides a clean two-second lag between the scroll place and the animation, giving it a extra pure, fluid
really feel. Strive experimenting with totally different values to regulate the responsiveness. -
animation:
Defines the precise rotation impact:-
Begins at
rotateX: -80
levels (the cylinder is tilted barely backward). -
Ends at
rotateX: 270
levels (the cylinder completes nearly a full rotation). -
ease: "none"
ensures a linear development that instantly matches the scroll place.
-
Begins at
As customers scroll, the cylinder easily rotates, revealing every textual content merchandise in sequence. The prolonged scroll length (
2000svh
) offers viewers time to completely admire the 3D impact at their very own tempo.
When you ever must tweak how the animation feels, give attention to the
scrub
and
finish
values — they instantly management how
ScrollTrigger
interpolates scroll velocity into animation time.
Facet Observe: Understanding Backface Visibility
We talked about it earlier, however
backface-visibility
performs a vital position in our cylindrical animation.
.cylinder__text__item {
place: absolute;
prime: 50%;
left: 50%;
width: 100%;
backface-visibility: hidden;
}
This property hides textual content gadgets once they rotate away from the viewer—when their “again” aspect is seen. With out it,
you’d see mirrored, reversed textual content as gadgets rotate previous 90 levels, breaking the phantasm of depth. By setting
backface-visibility: hidden;
, solely the front-facing textual content is displayed, making a clear and convincing 3D rotation.
With out this property, you would possibly find yourself with one thing like this:

Dealing with Responsive Conduct
As a result of the cylinder’s dimensions are primarily based on the viewport measurement, we have to recalculate their positions at any time when the
window is resized. The
resize()
methodology takes care of this:
resize(): void {
this.calculatePositions();
}
This methodology is known as from the primary
App
class, which listens for window resize occasions:
// src/major.ts
addEventListeners(): void {
window.addEventListener("resize", () => {
this.cylinder.resize();
});
}
This ensures that when customers rotate their system, resize their browser, or change between portrait and panorama
modes, the cylinder maintains its right proportions and positioning. The impact stays visually constant and
preserves the 3D phantasm throughout all display sizes.
The Second Impact: Circle
The double circle impact is a private favourite — it exhibits how we are able to obtain elegant, dynamic animations utilizing only a
few intelligent mixtures of CSS and JavaScript.
HTML Construction
The circle impact makes use of a dual-column structure with two separate lists of textual content gadgets positioned on reverse sides of the
viewport.
<part class="circle__wrapper">
<ul class="circle__text__wrapper__left">
<li class="circle__text__left__item">design</li>
<li class="circle__text__left__item">growth</li>
<li class="circle__text__left__item">branding</li>
<!-- 24 gadgets whole -->
</ul>
<ul class="circle__text__wrapper__right">
<li class="circle__text__right__item">design</li>
<li class="circle__text__right__item">growth</li>
<li class="circle__text__right__item">branding</li>
<!-- 24 gadgets whole -->
</ul>
</part>
We use two unordered lists (
<ul>
) to create unbiased textual content columns. Every checklist accommodates 24 equivalent gadgets that will likely be organized alongside round paths
in the course of the scroll animation. The left and proper wrappers allow mirrored round movement on reverse sides of the
display, including symmetry to the general impact.
CSS Basis
The
.circle__wrapper
class defines our major animation container:
.circle__wrapper {
place: relative;
width: 100%;
peak: 100svh;
}
In contrast to the cylindrical impact, we don’t want
perspective
or
transform-style: preserve-3d
right here, since this impact depends on 2D round movement somewhat than true 3D depth. Every wrapper merely fills the viewport
peak, forming a clear, full-screen scroll part.
Positioning the Textual content Columns
The left column is positioned about 30% from the left fringe of the display:
.circle__text__wrapper__left {
place: absolute;
prime: 50%;
left: 30%;
translate: -100% -50%;
}
The precise column is positioned at 70% from the left edge, mirroring the left column’s place:
.circle__text__wrapper__right {
place: absolute;
prime: 50%;
left: 70%;
translate: 0 -50%;
text-align: proper;
}
Each wrappers are vertically centered utilizing
prime: 50%
and
translate: ... -50%
. The important thing distinction lies of their horizontal alignment: the left wrapper makes use of
-100%
to shift it absolutely to the left, whereas the suitable wrapper makes use of
0
together with
text-align: proper
to align its textual content to the suitable aspect.
Positioning Particular person Textual content Objects
Every textual content merchandise is totally positioned and centered inside its respective wrapper:
.circle__text__left__item,
.circle__text__right__item {
place: absolute;
font-size: 3rem;
font-weight: 700;
text-transform: uppercase;
rework: translate(-50%, -50%);
}
The
rework: translate(-50%, -50%)
rule facilities every merchandise at its rework origin. At this level, all textual content parts are stacked on prime of one another in
the center of their respective wrappers. That is intentional — we’ll use JavaScript subsequent to calculate every merchandise’s
place alongside a round path, creating the orbital movement impact.
For now, your structure ought to look one thing like this:

We’re not fairly there but — the textual content gadgets are nonetheless stacked on prime of one another as a result of all of them share the identical
place. To repair this, we’ll deal with the round positioning with JavaScript, calculating every merchandise’s coordinates
alongside a round path. Whereas we may hardcode these positions in CSS, utilizing JavaScript permits the structure to
dynamically adapt to any display measurement or variety of gadgets.
Bringing the Circle Impact to Life with JavaScript
We’ll begin by creating a brand new folder named
circle
for this impact and defining a
Circle
class to deal with all its performance.
First, we’ll outline a configuration interface and initialize the mandatory DOM parts for each circles:
interface CircleConfig {
wrapper: HTMLElement;
gadgets: NodeListOf<HTMLElement>;
radius: quantity;
route: quantity;
}
export class Circle {
leftConfig: CircleConfig;
rightConfig: CircleConfig;
centerX!: quantity;
centerY!: quantity;
constructor() {
this.leftConfig = {
wrapper: doc.querySelector(".circle__text__wrapper__left") as HTMLElement,
gadgets: doc.querySelectorAll(".circle__text__left__item"),
radius: 0,
route: 1,
};
this.rightConfig = {
wrapper: doc.querySelector(".circle__text__wrapper__right") as HTMLElement,
gadgets: doc.querySelectorAll(".circle__text__right__item"),
radius: 0,
route: -1,
};
this.updateDimensions();
this.init();
}
}
We use a
CircleConfig
interface to retailer configuration information for every circle. It accommodates the next properties:
-
wrapper
: The container ingredient that holds every checklist of textual content gadgets. -
gadgets
: All particular person textual content parts inside that checklist. -
radius
: The circle’s radius, calculated dynamically primarily based on the wrapper’s width. -
route
: Determines the rotation route —
1
for clockwise and
-1
for counterclockwise.
Discover that the left circle has
route: 1
, whereas the suitable circle makes use of
route: -1
. This setup creates completely mirrored movement between the 2 sides.
Calculating Dimensions
Earlier than positioning any textual content gadgets, we have to calculate the middle level of the viewport and decide every circle’s
radius. These values will function the muse for positioning each merchandise alongside the round paths.
updateDimensions(): void {
this.centerX = window.innerWidth / 2;
this.centerY = window.innerHeight / 2;
this.leftConfig.radius = this.leftConfig.wrapper.offsetWidth / 2;
this.rightConfig.radius = this.rightConfig.wrapper.offsetWidth / 2;
}
The middle coordinates (
centerX
and
centerY
) outline the purpose round which our circles orbit. Every circle’s radius is calculated as half the width of its
wrapper, making certain the round path scales proportionally with the container measurement.
As soon as we’ve decided these dimensions, we are able to initialize each circles and start positioning the textual content gadgets round
their respective paths:
init(): void {
this.calculateInitialPositions();
}
calculateInitialPositions(): void {
this.updateItemsPosition(this.leftConfig, 0);
this.updateItemsPosition(this.rightConfig, 0);
}
We name
updateItemsPosition()
for each configurations, utilizing
scrollY: 0
because the preliminary worth. The
scrollY
parameter will come into play later after we add scroll-triggered animation—extra on that quickly.
Understanding the Place Calculations
The
updateItemsPosition()
methodology is the place the true magic occurs. Let’s break down the way it arranges the textual content gadgets evenly across the invisible
round paths:
updateItemsPosition(config: CircleConfig, scrollY: quantity): void {
const { gadgets, radius, route } = config;
const totalItems = gadgets.size;
const spacing = Math.PI / totalItems;
gadgets.forEach((merchandise, index) => {
const angle = index * spacing - scrollY * route * Math.PI * 2;
const x = this.centerX + Math.cos(angle) * radius;
const y = this.centerY + Math.sin(angle) * radius;
const rotation = (angle * 180) / Math.PI;
gsap.set(merchandise, {
x,
y,
rotation,
transformOrigin: "heart heart",
});
});
}
Distributing gadgets evenly
To make sure even spacing, we calculate the space between every textual content merchandise by dividing π (180 levels in radians) by the
whole variety of gadgets. This evenly distributes the textual content throughout half of the circle’s circumference:
const spacing = Math.PI / totalItems;
Calculating every merchandise’s place
Subsequent, we use primary trigonometry to calculate the place of every textual content merchandise alongside the round path:
const angle = index * spacing - scrollY * route * Math.PI * 2;
const x = this.centerX + Math.cos(angle) * radius;
const y = this.centerY + Math.sin(angle) * radius;
const rotation = (angle * 180) / Math.PI;
-
angle
: Calculates the present angle in radians. The
scrollY * route * Math.PI * 2
half will later management rotation primarily based on scroll place. -
x
(horizontal place): Makes use of
Math.cos(angle) * radius
to find out the horizontal coordinate relative to the circle’s heart. -
y
(vertical place): Makes use of
Math.sin(angle) * radius
to calculate the vertical coordinate, positioning gadgets alongside the round path. -
rotation
: Converts the angle again into levels and rotates every merchandise in order that it naturally follows the curve of the circle.
Lastly, we use GSAP’s
gsap.set()
methodology to use these calculated positions and rotations to every textual content merchandise:
gsap.set(merchandise, {
x,
y,
rotation,
transformOrigin: "heart heart",
});
Utilizing
gsap.set()
as a substitute of manually updating kinds ensures GSAP retains observe of all transforms it applies. When you later add tweens or
timelines on the identical parts, GSAP will reuse its inner state somewhat than overwriting CSS instantly.
This produces the next visible end result:

It’s trying significantly better! Each circles at the moment are seen and forming properly, however there’s one difficulty — the textual content on the
proper aspect is the wrong way up and arduous to learn. To repair this, we’ll want to regulate the rotation so that every one textual content stays
upright because it strikes alongside the circle.
Conserving the Textual content Readable
To keep up readability, we’ll add a conditional rotation offset primarily based on every circle’s route. This ensures the
textual content on either side all the time faces the right approach:
const rotationOffset = route === -1 ? 180 : 0;
const rotation = (angle * 180) / Math.PI + rotationOffset;
When
route === -1
(the suitable circle), we add 180 levels to flip the textual content so it seems right-side up. When
route === 1
(the left circle), we hold it at 0 levels, preserving the default orientation. This adjustment ensures that every one textual content
stays readable because it strikes alongside its round path.
With that small tweak, our circles now appear to be this:

Good! Each circles at the moment are absolutely readable and prepared for scroll-triggered animation.
Animating with ScrollTrigger
Now that we’ve positioned our textual content alongside round paths, let’s create a brand new perform known as
createScrollAnimations()
to deliver them to life by linking their movement to the consumer’s scroll place.
Setting Up ScrollTrigger
We’ll use GSAP’s
ScrollTrigger.create()
methodology to outline when and the way our animation behaves as customers scroll by the part:
createScrollAnimations(): void {
ScrollTrigger.create({
set off: ".circle__wrapper",
begin: "prime backside",
finish: "backside prime",
scrub: 1,
onUpdate: (self) => {
const scrollY = self.progress * 0.5;
this.updateItemsPosition(this.leftConfig, scrollY);
this.updateItemsPosition(this.rightConfig, scrollY);
},
});
}
And right here’s our closing end in motion:
Breaking Down the Configuration
set off:
The ingredient that initiates the animation — on this case,
.circle__wrapper
. When this part enters the viewport, the animation begins.
begin:
The worth
"prime backside"
means the animation begins when the highest of the set off reaches the underside of the viewport. In different phrases, the
rotation begins as quickly because the circle part comes into view.
finish:
The worth
"backside prime"
signifies the animation completes when the underside of the set off reaches the highest of the viewport. This creates a
clean, prolonged scroll length the place the circles proceed rotating all through the part’s visibility.
scrub:
Setting
scrub: 1
provides a one-second delay between scroll motion and animation updates, giving the movement a clean, pure really feel. You
can tweak this worth — larger numbers create a softer easing impact, whereas decrease numbers make the animation reply
extra instantly.
onUpdate:
This callback runs repeatedly because the consumer scrolls by the part. It’s liable for linking the scroll
progress to the round rotation of our textual content gadgets:
onUpdate: (self) => {
const scrollY = self.progress * 0.5;
this.updateItemsPosition(this.leftConfig, scrollY);
this.updateItemsPosition(this.rightConfig, scrollY);
}
-
self.progress
returns a worth between 0 and 1, representing how far the consumer has scrolled by the animation. -
We multiply this worth by
0.5
to regulate the rotation pace. This implies the circles will full half a rotation throughout a full scroll by the
part. You possibly can tweak this multiplier to make the circles spin sooner or slower. -
Lastly, we name
updateItemsPosition()
for each circles, passing within the calculated
scrollY
worth to replace their positions in actual time.
The
onUpdate
callback runs each animation body whereas scrolling, providing you with direct entry to reside scroll progress. This sample is
excellent if you’re mixing customized math-based transforms with GSAP as you continue to get exact, throttled body updates
with out dealing with
requestAnimationFrame
your self.
Bear in mind the
scrollY * route * Math.PI * 2
system inside our
updateItemsPosition()
methodology? That is the place it comes into play. Because the consumer scrolls:
-
The left circle (
route: 1
) rotates clockwise. -
The precise circle (
route: -1
) rotates counterclockwise. - Each circles transfer in excellent synchronization with the scroll place, making a balanced mirrored movement.
The result’s an attractive dual-circle animation the place textual content gadgets orbit easily as customers scroll, including a dynamic and
visually partaking movement to your structure.
The Third Impact: Tube
The third impact introduces a “tube” or “tunnel” animation, the place textual content gadgets are stacked alongside the depth axis,
creating a way of 3D movement as customers scroll ahead by the scene.
HTML Construction
<part class="tube__wrapper">
<ul class="tube__text__wrapper">
<li class="tube__text__item">design</li>
<li class="tube__text__item">growth</li>
<li class="tube__text__item">branding</li>
<li class="tube__text__item">advertising and marketing</li>
<li class="tube__text__item">copywriting</li>
<li class="tube__text__item">content material</li>
<li class="tube__text__item">illustration</li>
<li class="tube__text__item">video</li>
</ul>
</part>
In contrast to the Circle impact, which makes use of two opposing columns, the Tube impact makes use of a single checklist the place every merchandise is
positioned alongside the Z-axis. This creates the phantasm of depth, as if the textual content is receding into or rising from a 3D
tunnel.
CSS Basis
.tube__wrapper {
width: 100%;
peak: 100svh;
place: relative;
perspective: 70vw;
overflow: hidden;
}
We’re again to utilizing
perspective: 70vw
, similar to within the Cylinder impact. This property creates the sense of depth wanted for our 3D tunnel phantasm. The
overflow: hidden
rule ensures that textual content parts don’t seem outdoors the seen bounds as they transfer by the tunnel.
.tube__text__wrapper {
width: 100%;
peak: 100%;
place: relative;
transform-style: preserve-3d;
transform-origin: heart heart;
}
The
transform-style: preserve-3d
property permits youngster parts to take care of their 3D positioning inside the scene — a vital step in creating the
tunnel depth impact that makes this animation really feel immersive.
.tube__text__item {
place: absolute;
prime: 50%;
width: 100%;
}
Every textual content merchandise is vertically centered and stretches throughout the complete width of the container. At this level, all gadgets
are stacked on prime of each other in the identical place. Within the subsequent step, we’ll use JavaScript to distribute them
alongside the Z-axis, giving the phantasm of textual content rising from or receding right into a tunnel because the consumer scrolls.
Including Movement with JavaScript
Identical to with the Cylinder impact, we’ll create a
Tube
class to handle our 3D tunnel animation — dealing with initialization, positioning, and scroll-based transformations.
Initialization
export class Tube {
personal gadgets: NodeListOf<HTMLElement>;
personal textWrapper: HTMLElement;
personal wrapper: HTMLElement;
constructor() {
this.wrapper = doc.querySelector(".tube__wrapper") as HTMLElement;
this.textWrapper = doc.querySelector(".tube__text__wrapper") as HTMLElement;
this.gadgets = doc.querySelectorAll(".tube__text__item");
this.init();
}
personal init(): void {
this.calculatePositions();
}
}
Right here, we initialize the identical core DOM parts used within the Cylinder impact: the wrapper (which we’ll later pin throughout
scrolling), the textual content wrapper (which we’ll rotate), and the person textual content gadgets (which we’ll place in 3D house).
Place Calculation
The
calculatePositions()
methodology works very like the one used within the Cylinder impact, with one key distinction — as a substitute of a vertical rotation,
this time we’re constructing a horizontal cylinder, giving the phantasm of transferring by a tunnel.
personal calculatePositions(): void {
const offset = 0.4;
const radius = Math.min(window.innerWidth, window.innerHeight) * offset;
const spacing = 360 / this.gadgets.size;
this.gadgets.forEach((merchandise, index) => {
const angle = (index * spacing * Math.PI) / 180;
const x = Math.sin(angle) * radius;
const y = 0;
const z = Math.cos(angle) * radius;
const rotationY = index * spacing;
merchandise.fashion.rework = `translate3d(${x}px, ${y}px, ${z}px) rotateY(${rotationY}deg)`;
});
}
The underlying math is nearly equivalent to the Cylinder impact — we nonetheless calculate a radius, distribute gadgets evenly
throughout 360 levels, and use trigonometric features to find out every merchandise’s place. The important thing variations come from
how we map these calculations to the axes:
-
X-axis (horizontal):
Makes use of
Math.sin(angle) * radius
to create horizontal spacing between gadgets. -
Y-axis (vertical):
Set to
0
to maintain all textual content gadgets completely centered vertically. -
Rotation:
Makes use of
rotateY()
as a substitute of
rotateX()
, rotating every merchandise across the vertical axis to create a convincing tunnel-like perspective.
This setup types a horizontal tube that extends into the display’s depth — excellent for making a clean, scroll-driven
tunnel animation.
The construction is in place, but it surely nonetheless feels static — let’s deliver it to life with animation!

ScrollTrigger Animation
Identical to within the Cylinder impact, we’ll use
ScrollTrigger.create()
to synchronize our tube’s rotation with the consumer’s scroll place:
personal createScrollTrigger(): void {
ScrollTrigger.create({
set off: ".tube__title",
begin: "heart heart",
finish: "+=2000svh",
pin: this.wrapper,
scrub: 2,
animation: gsap.fromTo(
this.textWrapper,
{ rotateY: 0 },
{ rotateY: 360, ease: "none" }
),
});
}
The configuration carefully mirrors the Cylinder setup, with only one main distinction — the axis of rotation:
-
set off:
Prompts when the
.tube__title
ingredient enters the viewport. -
begin / finish:
Defines an extended
2000svh
scroll distance for a clean, steady animation. -
pin:
Retains the whole tube part fastened in place whereas the animation performs. -
scrub:
Provides a two-second delay for clean, scroll-synced movement. -
animation:
The important thing change — utilizing
rotateY
as a substitute of
rotateX
to spin the tunnel round its vertical axis.
Whereas the Cylinder impact rotates across the horizontal axis (like a Ferris wheel), the Tube impact spins across the
vertical axis — extra like a tunnel spinning towards the viewer. This creates a dynamic phantasm of depth, making it
really feel as if you happen to’re touring by 3D house as you scroll.
Reusing the identical
ScrollTrigger
setup between totally different results is an efficient sample as a result of it retains scroll-linked movement constant throughout your web site.
You possibly can swap axes, durations, or easing with out rewriting your scroll logic.
The ultimate result’s a hypnotic tunnel animation the place textual content gadgets seem to hurry towards and previous the viewer, delivering
a real sense of movement by a cylindrical world.
Conclusion
Thanks for following together with this tutorial! We’ve explored three distinctive 3D textual content scroll results — the Cylinder,
Circle, and Tube animations — every demonstrating a unique method to constructing immersive scroll-driven experiences
utilizing GSAP, ScrollTrigger, and inventive 3D CSS transforms.
When tuning typography and colours, you’ll be able to create every kind of mesmerizing seems for these results! Try the ultimate demos:
I can’t wait to see what you provide you with utilizing these methods! Be happy to tweak the parameters, combine the consequences, or create totally new variations of your individual. When you construct one thing superior, I’d like to test it out — share your work with me on LinkedIn, Instagram, or X (Twitter).


