Kitne aadmi the?
Mogambo khush hua.
Picture abhi baaki
hai mere dost.
Don ko pakadna
mushkil hi nahi,
namumkin hai.
How's the josh?
High sir!

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.

effectcylinder · drum · flip — default "cylinder".
byline · word — default "line".
sizesm · md · lg — default "md".
maxAngle20–80° — default 55°.
spacing0.6–1.4 — default 0.85.
perspective500–1600px — default 1000px.
hoverEffectlift · magnet · wave · scale · none — default "lift".
colorcolour — default #ffffff.
gradientnone · blue · sunset · neon · mint — default "none".
perLineon / off — default on.

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-type

How 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

linesstring[] — the lines to roll through. Required.
effect"cylinder" (default), "drum" (deeper 3D) or "flip" (per-line flip).
by"line" (default) rolls whole lines; "word" rolls each word.
size"sm" | "md" (default) | "lg" — the fluid font-size preset.
maxAngleAngle spread of the cylinder — higher curves harder and packs the far lines tighter. Default 55.
spacingGap between lines as a multiple of the line height — lower is tighter. Default 0.85.
hoverEffectSpring-physics cursor reaction: "lift" (pop forward + tilt, default), "magnet" (slide toward the cursor), "wave" (gaussian ripple so neighbours react too), "scale" (grow), or "none".
perspective3D depth in px — lower is more extreme. Default 1000.
colorBase text colour. Default "#ffffff".
gradientA CSS gradient painted through the text via background-clip, e.g. "linear-gradient(95deg,#fff,#3b82f6)". Wins over color / colors.
colorsstring[] — per-sentence colours, cycled by line index, so each line reads in its own colour.
autoPlaySelf-scroll the roll (down then back up) — used for previews with no pointer.

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.

Source code
"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.

Command Palette

Search for a command to run...