Rolling Type
Lines of text that roll in 3D as you scroll — the centre line lies flat and sharp while the rest curve away, like text on a rotating drum.
A free, copy-paste scroll animations component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- Scroll to roll the lines through in 3D
- The centre line lies flat; the rest curve away
- Roll whole lines or each word on its own
Dependency
No dependencies — plain React. Just drop the file in.
Point the CLI here once
A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:
npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/rolling-type.json — or copy the file from Source code below.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/rolling-typeHow to use
Give Rolling Type an array of lines and it stacks them in a self-contained scroll box. As the reader scrolls, each line rotates around the X-axis by its distance from the centre — the middle line sits flat and legible while the ones above and below curve away in 3D, so the whole block reads like text wrapped on a slowly turning drum.
It's a single scroll-driven component — no scroll libraries, no Motion, no GSAP. The 3D is a rAF-throttled pass that reads each line's position and writes a transform, so it tracks the scroll 1:1 and stays smooth. It fills its parent (give the parent a height), works in a framed card or full-screen, and respects prefers-reduced-motion.
Basic usage
import { RollingType } from "@/components/harsh-ui/rolling-type";
const Demo = () => (
<div className="h-screen bg-neutral-950 text-white">
<RollingType
lines={[
"We are",
"the simplest.",
"No complexity.",
"No confusion.",
]}
/>
</div>
);Effects + tuning
effect picks how the lines move — "cylinder" (curved stack, default), "drum" (deeper, lines recede) or "flip" (each line flips past 90°). Widen maxAngle or lower perspective for a more extreme roll.
<RollingType
lines={lines}
effect="drum"
size="lg"
maxAngle={65}
perspective={800}
/>Roll by word
by="word" splits each line into words and rolls them individually, so a wrapping line curls across multiple rows.
<RollingType lines={lines} by="word" />Demo
import { RollingTypeDemo } from "@/components/demos/rolling-type-demo";
const Demo = () => (
<div className="h-screen w-full">
<RollingTypeDemo />
</div>
);Props
Notes
- Zero dependencies — the roll is a rAF loop writing CSS transforms. No Motion, no GSAP.
- Each line sits on a real 3D cylinder (translateY + translateZ + rotateX), so the front line is flat and large while the rest curve away and recede — matching the reference.
- Smoothness comes from decoupling: an invisible native-scroll track captures the wheel/touch, and the visible text is an overlay whose transforms are driven by a virtual position that LERPS toward the scroll — so discrete wheel steps become an eased roll instead of snapping.
- Distance-to-centre drives opacity so the far lines fade out instead of piling up; perspective lives on each line's own transform since overflow: auto flattens a shared 3D context.
- autoPlay rolls the box down then back up on a loop and steps aside the moment the reader scrolls, resuming shortly after.
Source code
The complete rolling-type.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import { useEffect, useRef, useState } from "react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* RollingType
* Lines wrapped on a 3D cylinder that rolls as you scroll: the line at the front
* lies flat and sharp while the rest curve away and recede around the X-axis.
* An invisible native-scroll track captures the wheel/touch; a rAF loop lerps a
* virtual position toward it and writes each line's transform — so the roll is
* smooth and eased instead of snapping to raw wheel steps. Zero dependencies —
* no Motion, no GSAP.
*/
export type RollingEffect = "cylinder" | "drum" | "flip";
export type RollingBy = "line" | "word";
export type RollingSize = "sm" | "md" | "lg";
export type RollingHover = "none" | "lift" | "magnet" | "wave" | "scale";
export type RollingTypeProps = {
lines: string[];
effect?: RollingEffect;
by?: RollingBy;
size?: RollingSize;
maxAngle?: number;
spacing?: number;
hoverEffect?: RollingHover;
perspective?: number;
color?: string;
gradient?: string;
colors?: string[];
autoPlay?: boolean;
className?: string;
};
const SIZES: Record<RollingSize, string> = {
sm: "clamp(1.4rem, 4.5vw, 3rem)",
md: "clamp(1.9rem, 6.5vw, 4.6rem)",
lg: "clamp(2.5rem, 9vw, 6.8rem)",
};
export function RollingType({
lines,
effect = "cylinder",
by = "line",
size = "md",
maxAngle = 55,
spacing = 0.85,
hoverEffect = "lift",
perspective = 1000,
color = "var(--foreground)",
gradient,
colors,
autoPlay = false,
className = "",
}: RollingTypeProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const [m, setM] = useState({ boxH: 0, lineH: 0 });
const fontSize = SIZES[size] || SIZES.md;
const units =
by === "word"
? lines.flatMap((l) => l.split(/\s+/).filter(Boolean))
: lines;
const N = units.length;
const scrollStep = Math.max(70, m.lineH * 1.5);
const maxScroll = Math.max(1, (N - 1) * scrollStep);
const trackHeight = m.boxH + maxScroll;
useEffect(() => {
const measure = () => {
const boxH = wrapRef.current?.clientHeight ?? 0;
const lineH = measureRef.current?.offsetHeight ?? 0;
setM((prev) =>
prev.boxH === boxH && prev.lineH === lineH ? prev : { boxH, lineH },
);
};
measure();
window.addEventListener("resize", measure);
const t = setTimeout(measure, 120);
return () => {
window.removeEventListener("resize", measure);
clearTimeout(t);
};
}, [size, N]);
useEffect(() => {
const scroller = scrollRef.current;
const wrap = wrapRef.current;
if (!scroller || !wrap || m.lineH === 0) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const stepRad = ((maxAngle / 2.6) * Math.PI) / 180;
const R = (m.lineH * spacing) / Math.sin(stepRad);
const drumZ = effect === "drum" ? 1.5 : 1;
const written: string[] = new Array(N);
const hz = new Float32Array(N);
const hzv = new Float32Array(N);
const hr = new Float32Array(N);
const hrv = new Float32Array(N);
const hx = new Float32Array(N);
const hxv = new Float32Array(N);
const hs = new Float32Array(N);
const hsv = new Float32Array(N);
const pointer = { x: 0, y: 0, on: false };
let springing = false;
let current = scroller.scrollTop;
let raf = 0;
let onScreen = true;
let dir = 1;
let paused = false;
let resume: ReturnType<typeof setTimeout>;
const paint = () => {
const focus = (current / maxScroll) * (N - 1);
for (let i = 0; i < N; i++) {
const el = lineRefs.current[i];
if (!el) continue;
const off = i - focus;
const a = Math.abs(off);
if (reduce) {
const key = `r${off.toFixed(2)}`;
if (written[i] === key) continue;
written[i] = key;
el.style.transform = `translate3d(0, ${(off * m.lineH * 1.25).toFixed(1)}px, 0)`;
el.style.opacity = Math.max(0.15, 1 - a * 0.3).toFixed(3);
continue;
}
const th = off * stepRad;
let y: number;
let z: number;
let rotX: number;
let o: number;
if (effect === "flip") {
y = off * m.lineH * 1.15;
z = 0;
rotX = off * 95;
o = Math.max(0, Math.min(1, 1.15 - a * 0.4));
} else {
y = R * Math.sin(th);
z = R * (Math.cos(th) - 1) * drumZ;
rotX = (th * 180) / Math.PI;
o =
Math.abs(th) >= Math.PI / 2
? 0
: Math.max(0, Math.min(1, Math.cos(th) * 2.2));
}
let tz = 0;
let tr = 0;
let tx = 0;
let ts = 0;
if (hoverEffect !== "none" && pointer.on && o > 0.01) {
const dy = Math.abs(y - pointer.y);
if (hoverEffect === "wave") {
const k = dy / (m.lineH * 1.7);
const f = Math.exp(-k * k);
tz = 70 * f;
tr = Math.max(-10, Math.min(10, (pointer.x / 14) * f));
} else if (dy < m.lineH * 0.85) {
if (hoverEffect === "lift") {
tz = 70;
tr = Math.max(-16, Math.min(16, pointer.x / 12));
} else if (hoverEffect === "magnet") {
tx = Math.max(-90, Math.min(90, pointer.x * 0.22));
tz = 30;
} else if (hoverEffect === "scale") {
ts = 0.18;
tz = 24;
}
}
}
hzv[i] = (hzv[i] + (tz - hz[i]) * 0.16) * 0.76;
hz[i] += hzv[i];
hrv[i] = (hrv[i] + (tr - hr[i]) * 0.16) * 0.76;
hr[i] += hrv[i];
hxv[i] = (hxv[i] + (tx - hx[i]) * 0.16) * 0.76;
hx[i] += hxv[i];
hsv[i] = (hsv[i] + (ts - hs[i]) * 0.16) * 0.76;
hs[i] += hsv[i];
if (
Math.abs(hzv[i]) > 0.05 ||
Math.abs(hxv[i]) > 0.05 ||
Math.abs(tz - hz[i]) > 0.05 ||
Math.abs(tx - hx[i]) > 0.05 ||
Math.abs(ts - hs[i]) > 0.002
)
springing = true;
const t = `perspective(${perspective}px) translate3d(${hx[i].toFixed(1)}px, ${y.toFixed(1)}px, ${(z + hz[i]).toFixed(1)}px) rotateX(${rotX.toFixed(2)}deg) rotateY(${hr[i].toFixed(2)}deg) scale(${(1 + hs[i]).toFixed(3)})`;
const key = `${t}|${o.toFixed(3)}`;
if (written[i] === key) continue;
written[i] = key;
el.style.transform = t;
el.style.opacity = o.toFixed(3);
el.style.visibility = o < 0.01 ? "hidden" : "visible";
}
};
const frame = () => {
if (autoPlay && !paused) {
const max = scroller.scrollHeight - scroller.clientHeight;
if (max > 0) {
scroller.scrollTop += dir * 1.3;
if (scroller.scrollTop >= max) dir = -1;
else if (scroller.scrollTop <= 0) dir = 1;
}
}
const target = Math.max(0, Math.min(maxScroll, scroller.scrollTop));
current += (target - current) * (reduce ? 1 : 0.09);
const settled = Math.abs(target - current) < 0.05;
if (settled) current = target;
springing = false;
paint();
if (settled && !springing && !(autoPlay && !paused)) {
raf = 0;
return;
}
raf = requestAnimationFrame(frame);
};
const kick = () => {
if (!raf && onScreen) raf = requestAnimationFrame(frame);
};
const onInteract = () => {
if (!autoPlay) return;
paused = true;
clearTimeout(resume);
resume = setTimeout(() => {
paused = false;
kick();
}, 2200);
};
const onMove = (e: PointerEvent) => {
if (hoverEffect === "none") return;
const r = wrap.getBoundingClientRect();
pointer.x = e.clientX - (r.left + r.width / 2);
pointer.y = e.clientY - (r.top + r.height / 2);
pointer.on = true;
kick();
};
const onLeave = () => {
pointer.on = false;
kick();
};
scroller.addEventListener("scroll", kick, { passive: true });
wrap.addEventListener("pointermove", onMove, { passive: true });
wrap.addEventListener("pointerleave", onLeave);
scroller.addEventListener("wheel", onInteract, { passive: true });
scroller.addEventListener("touchstart", onInteract, { passive: true });
scroller.addEventListener("pointerdown", onInteract);
const io = new IntersectionObserver(
([e]) => {
onScreen = e.isIntersecting;
if (onScreen) kick();
else if (raf) {
cancelAnimationFrame(raf);
raf = 0;
}
},
{ threshold: 0 },
);
io.observe(wrap);
kick();
return () => {
io.disconnect();
if (raf) cancelAnimationFrame(raf);
clearTimeout(resume);
scroller.removeEventListener("scroll", kick);
wrap.removeEventListener("pointermove", onMove);
wrap.removeEventListener("pointerleave", onLeave);
scroller.removeEventListener("wheel", onInteract);
scroller.removeEventListener("touchstart", onInteract);
scroller.removeEventListener("pointerdown", onInteract);
};
}, [effect, maxAngle, spacing, hoverEffect, perspective, N, m.lineH, maxScroll, autoPlay]);
return (
<div ref={wrapRef} className={`relative h-full overflow-hidden ${className}`}>
<span
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 font-bold leading-none"
style={{ fontSize }}
>
Mg
</span>
<div
ref={scrollRef}
className="no-scrollbar absolute inset-0 overflow-y-auto overscroll-contain"
>
<div style={{ height: `${trackHeight}px` }} />
</div>
<div
className="pointer-events-none absolute inset-0 overflow-hidden text-center font-bold tracking-tight transition-opacity duration-300"
style={{ opacity: m.lineH ? 1 : 0 }}
>
{units.map((u, i) => (
<div key={`${u}-${i}`} className="absolute left-1/2 top-1/2">
<div
ref={(el) => {
lineRefs.current[i] = el;
}}
className="absolute"
style={{ transformOrigin: "0 0", backfaceVisibility: "hidden" }}
>
<span
className="block -translate-x-1/2 -translate-y-1/2 whitespace-nowrap"
style={{
fontSize,
...(gradient
? {
backgroundImage: gradient,
WebkitBackgroundClip: "text",
backgroundClip: "text",
color: "transparent",
}
: { color: colors?.length ? colors[i % colors.length] : color }),
}}
>
{u}
</span>
</div>
</div>
))}
</div>
</div>
);
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.