Pattern

Horizontal reel

A row scrolled sideways by scrolling down, with a progress hairline.

betav0.1.0 Chromium 103 ui-scale safe

Install

Copies the file into components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.
bunx shadcn@3 add @fastrp/horizontal-reel

Examples

Default

Open alone

Şehirde neler var

01
Kamu

Polis

02
Medya

Haber

03
Eğlence

Kumarhane

04
Kamu

Belediye

05
Sivil

Sokak

06
Ekonomi

İş dünyası

Sonraki bölüm

API

<HorizontalReel />

A row the reader scrolls sideways through by scrolling down: the section pins, and the track slides along for as long as it runs past the screen, with a progress hairline under it. The section is exactly that much taller than a screen, measured — so a card more or less needs no tuning.

PropTypeDefaultNotes
children*ReactNode—The cards; they keep their own widths — give each a fixed one.
classNamestring—
headerReactNode—Above the track: a title, a label.

Dependencies

npm

  • motion@^12.40.0

Registry

Source

components/ds/horizontal-reel.tsxShow
'use client';

import { motion, useScroll, useTransform } from 'motion/react';
import type * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useScrollRoot } from '@/components/ds/scroll-root';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

export type HorizontalReelProps = {
  /** Above the track: a title, a label. */
  header?: React.ReactNode;
  /** The cards; they keep their own widths — give each a fixed one. */
  children: React.ReactNode;
  className?: string;
};

/**
 * A row the reader scrolls sideways through by scrolling down: the section pins, and the track
 * slides along for as long as it runs past the screen, with a progress hairline under it. The
 * section is exactly that much taller than a screen, measured — so a card more or less needs no
 * tuning.
 */
function HorizontalReel({ header, children, className }: HorizontalReelProps) {
  const root = useScrollRoot();
  const sectionRef = useRef<HTMLElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const [overflow, setOverflow] = useState(0);

  useEffect(() => {
    const stage = stageRef.current;
    const track = trackRef.current;
    if (!stage || !track) return;
    const measure = () => setOverflow(Math.max(0, track.scrollWidth - stage.clientWidth));
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(track);
    observer.observe(stage);
    return () => observer.disconnect();
  }, []);

  const { scrollYProgress } = useScroll({
    container: root,
    target: sectionRef,
    offset: ['start start', 'end end'],
  });
  const x = useTransform(scrollYProgress, (progress) => -progress * overflow);

  return (
    <section
      ref={sectionRef}
      data-slot="horizontal-reel"
      // A measured length in real pixels, through a variable so the height stays a class.
      style={{ '--reel-overflow': `${overflow}px` } as React.CSSProperties}
      className={cn(
        'relative h-[calc(var(--scroll-root-h,100vh)+var(--reel-overflow))] supports-[height:100svh]:h-[calc(var(--scroll-root-h,100svh)+var(--reel-overflow))]',
        className,
      )}
    >
      <div
        ref={stageRef}
        className="sticky top-0 flex h-[var(--scroll-root-h,100vh)] flex-col justify-center gap-10 overflow-hidden supports-[height:100svh]:h-[var(--scroll-root-h,100svh)]"
      >
        {header && <div className="px-6 md:px-12 lg:px-20">{header}</div>}

        <motion.div
          ref={trackRef}
          style={{ x }}
          className="flex w-max gap-5 px-6 md:gap-8 md:px-12 lg:px-20"
        >
          {children}
        </motion.div>

        <div className="mx-6 h-px overflow-hidden bg-line md:mx-12 lg:mx-20">
          <motion.div
            style={{ scaleX: scrollYProgress }}
            className="h-full origin-left bg-foreground/70"
          />
        </div>
      </div>
    </section>
  );
}

export { HorizontalReel };