Skip to main content

Counter Scroll Wall

Two columns of cards drifting vertically in opposite directions on one rAF clock: each column doubles its content and wraps at half its scroll height for a seamless loop, the reverse column runs 1.25x faster for parallax, and hovering eases the whole wall to a stop instead of freezing it. Fully generic renderItem, masked edges, one or two columns.

GridReactMotionTailwind CSS
CSSTailwind

Manual

Create a file and paste the following code into it.

counter-scroll-wall.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"use client";

import { type ReactNode, useEffect, useRef, useState } from "react";
import { motion, useAnimationFrame, useMotionValue } from "motion/react";
import { cn } from "@/lib/cn";

/* ------------------------------------------------------------------ */
/*  CounterScrollWall                                                  */
/* ------------------------------------------------------------------ */

interface CounterScrollWallProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => ReactNode;
  /** Seconds for one full column loop [Optional, default: 20] */
  speed?: number;
  /** 1 or 2 columns — two run in opposite directions [Optional, default: 2] */
  columns?: 1 | 2;
  gap?: number;
  /** Fade height at the top and bottom edges [Optional, default: 120] */
  blurSize?: number;
  /** Ease the wall to a stop while hovered [Optional, default: true] */
  pauseOnHover?: boolean;
  className?: string;
}

function MarqueeColumn<T>({
  items,
  renderItem,
  speed,
  gap,
  reverse = false,
  paused,
}: {
  items: T[];
  renderItem: (item: T, index: number) => ReactNode;
  speed: number;
  gap: number;
  reverse?: boolean;
  paused: boolean;
}) {
  const wrapRef = useRef<HTMLDivElement | null>(null);
  const [singleHeight, setSingleHeight] = useState(0);
  const y = useMotionValue(0);
  const pos = useRef(0);
  const currentSpeed = useRef(0);
  const initialized = useRef(false);

  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    const measure = () => {
      const height = el.scrollHeight / 2;
      if (height <= 0) return;
      setSingleHeight(height);
      if (!initialized.current) {
        pos.current = reverse ? -height : 0;
        y.set(pos.current);
        initialized.current = true;
      }
    };
    const obs = new ResizeObserver(measure);
    obs.observe(el);
    measure();
    return () => obs.disconnect();
  }, [reverse, items, y]);

  useAnimationFrame((_, delta) => {
    if (!singleHeight) return;
    /* Ease toward the target speed so hover-pause decelerates instead of
       freezing — 8% of the gap per frame. */
    const target = paused ? 0 : singleHeight / speed;
    currentSpeed.current += (target - currentSpeed.current) * 0.08;

    const step = currentSpeed.current * (delta / 1000);
    if (reverse) {
      pos.current += step;
      if (pos.current >= 0) pos.current -= singleHeight;
    } else {
      pos.current -= step;
      if (pos.current <= -singleHeight) pos.current += singleHeight;
    }
    y.set(pos.current);
  });

  const doubled = [...items, ...items];
  return (
    <div className="min-w-0 flex-1 overflow-hidden">
      <motion.div ref={wrapRef} style={{ y }}>
        {doubled.map((item, index) => (
          <div key={index} className="w-full" style={{ marginBottom: `${gap}px` }}>
            {renderItem(item, index % items.length)}
          </div>
        ))}
      </motion.div>
    </div>
  );
}

/**
 * Two columns of cards drifting vertically in opposite directions on one
 * rAF clock: each column doubles its content and wraps at half its scroll
 * height for a seamless loop, the reverse column runs 1.25x faster, and
 * hovering eases the whole wall to a stop instead of freezing it. Top and
 * bottom edges dissolve through a mask.
 * @param {T[]} items - Cards to loop [Required]
 * @param {(item, index) => ReactNode} renderItem - Card renderer [Required]
 */
export function CounterScrollWall<T>({
  items,
  renderItem,
  speed = 20,
  columns = 2,
  gap = 16,
  blurSize = 120,
  pauseOnHover = true,
  className,
}: CounterScrollWallProps<T>) {
  const [paused, setPaused] = useState(false);
  const mid = Math.ceil(items.length / 2);
  const col1 = columns === 2 ? items.slice(0, mid) : items;
  const col2 = columns === 2 ? items.slice(mid) : items;
  const mask = `linear-gradient(to bottom, transparent 0%, black ${blurSize}px, black calc(100% - ${blurSize}px), transparent 100%)`;

  return (
    <div
      className={cn("h-full w-full overflow-hidden", className)}
      style={{ maskImage: mask, WebkitMaskImage: mask }}
      onMouseEnter={() => pauseOnHover && setPaused(true)}
      onMouseLeave={() => setPaused(false)}
    >
      <div className="flex h-full" style={{ gap: `${gap}px` }}>
        <MarqueeColumn
          items={col1.length ? col1 : items}
          renderItem={renderItem}
          speed={speed}
          gap={gap}
          paused={paused}
        />
        {columns === 2 && (
          <MarqueeColumn
            items={col2.length ? col2 : items}
            renderItem={renderItem}
            speed={speed * 1.25}
            gap={gap}
            reverse
            paused={paused}
          />
        )}
      </div>
    </div>
  );
}

Update the import paths to match your project setup.

Similar components

Depth Scroll Grid

Hover Expand Gallery

Max

Grid Cards

Bento Grid

Install via CLI

Resource details

PublishedJuly 17, 2026
CategoryGrid
ReactMotionTailwind CSS