Return

Chrono Rails

Two fixed clocks measure one quiet journey through the page.

chrono-railsscroll to explore

Notice the quiet rhythm.

Let motion organise the space.

Remove everything unnecessary.

Arrive without a hard stop.

Why it feels continuous: the dials and chapter fades share the same measured scroll value. A light interpolation removes wheel noise without disconnecting the scene from its local scroll frame.

Source

components/interior/chrono-rails.tsx
"use client";

import type { CSSProperties } from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";

export interface ChronoRailChapter {
  title: string;
}

export interface ChronoRailsProps {
  chapters?: readonly ChronoRailChapter[];
  className?: string;
  style?: CSSProperties;
}

const DEFAULT_CHAPTERS: readonly ChronoRailChapter[] = [
  {
    title: "Notice the quiet rhythm.",
  },
  {
    title: "Let motion organise the space.",
  },
  {
    title: "Remove everything unnecessary.",
  },
  {
    title: "Arrive without a hard stop.",
  },
];

const LEFT_VALUES = Array.from({ length: 12 }, (_, index) => String(index + 1).padStart(2, "0"));
const RIGHT_VALUES = Array.from({ length: 12 }, (_, index) => String((index + 1) * 5).padStart(2, "0"));

const clamp = (value: number, minimum = 0, maximum = 1) =>
  Math.min(maximum, Math.max(minimum, value));

function wrappedDistance(index: number, position: number, count: number) {
  const distance = index - position;
  return ((distance + count / 2) % count + count) % count - count / 2;
}

export function ChronoRails({
  chapters = DEFAULT_CHAPTERS,
  className,
  style,
}: ChronoRailsProps) {
  const viewportRef = useRef<HTMLElement>(null);
  const sceneRef = useRef<HTMLDivElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const leftRailRef = useRef<HTMLDivElement>(null);
  const rightRailRef = useRef<HTMLDivElement>(null);
  const leftCircleRef = useRef<HTMLDivElement>(null);
  const rightCircleRef = useRef<HTMLDivElement>(null);
  const leftLabelRefs = useRef<Array<HTMLSpanElement | null>>([]);
  const rightLabelRefs = useRef<Array<HTMLSpanElement | null>>([]);
  const chapterRefs = useRef<Array<HTMLElement | null>>([]);

  useEffect(() => {
    const viewport = viewportRef.current;
    const scene = sceneRef.current;
    const stage = stageRef.current;
    const leftRail = leftRailRef.current;
    const rightRail = rightRailRef.current;

    if (!viewport || !scene || !stage || !leftRail || !rightRail || chapters.length === 0) return;

    const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reducedMotion = motionQuery.matches;
    let currentProgress = 0;
    let targetProgress = 0;
    let frameId = 0;
    let isVisible = true;

    const readProgress = () => {
      const travel = Math.max(1, scene.offsetHeight - viewport.clientHeight);
      return clamp(viewport.scrollTop / travel);
    };

    const placeCircle = (
      circle: HTMLDivElement | null,
      radius: number,
      centreX: number,
      height: number,
    ) => {
      if (!circle) return;
      circle.style.width = `${radius * 2}px`;
      circle.style.height = `${radius * 2}px`;
      circle.style.left = `${centreX - radius}px`;
      circle.style.top = `${height / 2 - radius}px`;
      circle.style.borderWidth = `${clamp(radius * 0.15, 26, 76)}px`;
    };

    const paintDial = (
      labels: Array<HTMLSpanElement | null>,
      position: number,
      railWidth: number,
      radius: number,
      mirrored: boolean,
    ) => {
      const compactStage = stage.clientWidth < 420;
      const centreX = -radius * (compactStage ? 0.56 : 0.44);
      const centreY = stage.clientHeight / 2;
      const labelRadius = radius * 0.78;

      labels.forEach((label, index) => {
        if (!label) return;

        const distance = wrappedDistance(index, position, labels.length);
        const magnitude = Math.abs(distance);
        const angle = distance * 0.25;
        const proximity = Math.exp(-(distance * distance) * 1.8);
        const xOnArc = centreX + Math.cos(angle) * labelRadius;
        const x = mirrored ? railWidth - xOnArc : xOnArc;
        const y = centreY + Math.sin(angle) * labelRadius;
        const opacity = clamp(0.012 + 0.988 * Math.exp(-magnitude * 1.45));
        const rotation = (angle * 180) / Math.PI * (mirrored ? -0.82 : 0.82);
        const responsiveFontCap = stage.clientWidth < 420
          ? stage.clientWidth * 0.105
          : 52;

        label.style.opacity = opacity.toFixed(3);
        label.style.filter = `blur(${((1 - opacity) * 0.65).toFixed(2)}px)`;
        label.style.fontSize = `${Math.min(
          radius * (0.115 + proximity * 0.15),
          responsiveFontCap,
        )}px`;
        label.style.fontWeight = `${Math.round(440 + proximity * 330)}`;
        label.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%) rotate(${rotation}deg)`;
      });
    };

    const paint = (progress: number) => {
      stage.style.height = `${viewport.clientHeight}px`;
      const height = stage.clientHeight;
      const leftWidth = leftRail.clientWidth;
      const rightWidth = rightRail.clientWidth;
      const minimumRadius = stage.clientWidth < 420 ? 160 : 170;
      const radius = clamp(
        Math.min(height * 0.43, Math.max(leftWidth, rightWidth) * 1.72),
        minimumRadius,
        430,
      );
      const leftPosition = progress * (LEFT_VALUES.length - 1);
      const rightPosition = progress * (RIGHT_VALUES.length - 1);

      const circleOffset = stage.clientWidth < 420 ? 0.42 : 0.3;
      placeCircle(leftCircleRef.current, radius, -radius * circleOffset, height);
      placeCircle(rightCircleRef.current, radius, rightWidth + radius * circleOffset, height);
      paintDial(leftLabelRefs.current, leftPosition, leftWidth, radius, false);
      paintDial(rightLabelRefs.current, rightPosition, rightWidth, radius, true);

      const chapterPosition = progress * Math.max(0, chapters.length - 1);
      chapterRefs.current.forEach((chapter, index) => {
        if (!chapter) return;
        const distance = index - chapterPosition;
        const closeness = clamp(1 - Math.abs(distance));
        const opacity = closeness * closeness * (3 - 2 * closeness);

        chapter.style.opacity = opacity.toFixed(3);
        chapter.style.filter = `blur(${((1 - opacity) * 2.4).toFixed(2)}px)`;
        const chapterTravel = stage.clientWidth < 420
          ? Math.min(240, height * 0.82)
          : Math.min(190, height * 0.47);
        chapter.style.transform = `translate3d(-50%, calc(-50% + ${distance * chapterTravel}px), 0) scale(${0.965 + opacity * 0.035})`;
        chapter.style.visibility = opacity < 0.04 ? "hidden" : "visible";
      });

    };

    const animate = () => {
      const difference = targetProgress - currentProgress;
      currentProgress = reducedMotion
        ? targetProgress
        : currentProgress + difference * 0.115;
      paint(currentProgress);

      if (!reducedMotion && isVisible && Math.abs(targetProgress - currentProgress) > 0.0001) {
        frameId = requestAnimationFrame(animate);
      } else {
        currentProgress = targetProgress;
        paint(currentProgress);
        frameId = 0;
      }
    };

    const schedulePaint = () => {
      targetProgress = readProgress();
      if (reducedMotion) {
        currentProgress = targetProgress;
        paint(currentProgress);
        return;
      }
      if (!frameId && isVisible) frameId = requestAnimationFrame(animate);
    };

    const handleMotionPreference = (event: MediaQueryListEvent) => {
      reducedMotion = event.matches;
      schedulePaint();
    };

    const observer = new IntersectionObserver(
      ([entry]) => {
        isVisible = entry.isIntersecting;
        if (isVisible) schedulePaint();
        else if (frameId) {
          cancelAnimationFrame(frameId);
          frameId = 0;
        }
      },
      { rootMargin: "15% 0px" },
    );
    const resizeObserver = new ResizeObserver(schedulePaint);

    observer.observe(viewport);
    resizeObserver.observe(viewport);
    resizeObserver.observe(stage);
    viewport.addEventListener("scroll", schedulePaint, { passive: true });
    window.addEventListener("resize", schedulePaint, { passive: true });
    motionQuery.addEventListener("change", handleMotionPreference);

    currentProgress = readProgress();
    targetProgress = currentProgress;
    paint(currentProgress);

    return () => {
      if (frameId) cancelAnimationFrame(frameId);
      observer.disconnect();
      resizeObserver.disconnect();
      viewport.removeEventListener("scroll", schedulePaint);
      window.removeEventListener("resize", schedulePaint);
      motionQuery.removeEventListener("change", handleMotionPreference);
    };
  }, [chapters.length]);

  return (
    <section
      ref={viewportRef}
      className={cn(
        "relative h-72 overflow-y-auto rounded-xl border border-black/10 bg-[#f1f1ef] shadow-[0_20px_60px_rgba(29,27,22,0.08)] [scrollbar-width:none] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:h-96 [&::-webkit-scrollbar]:hidden",
        className,
      )}
      style={style}
      tabIndex={0}
      data-lenis-prevent
      aria-label="Chrono Rails scroll experiment"
    >
      <div ref={sceneRef} className="relative h-[320%]">
        <div
          ref={stageRef}
          className="sticky top-0 isolate overflow-hidden rounded-[inherit] bg-[#f1f1ef] text-[#20211f]"
          style={{
            backgroundImage:
              "radial-gradient(circle at 50% 42%, rgba(255,255,255,0.92), rgba(255,255,255,0) 47%), linear-gradient(rgba(38,42,38,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(38,42,38,0.025) 1px, transparent 1px)",
            backgroundSize: "auto, 28px 28px, 28px 28px",
          }}
      >
        <div
          ref={leftRailRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-y-0 left-0 w-[34%] min-w-[92px] max-w-[220px] overflow-visible"
        >
          <div ref={leftCircleRef} className="absolute box-border rounded-full border-solid border-[#20211f]/8" />
          {LEFT_VALUES.map((value, index) => (
            <span
              key={value}
              ref={(node) => {
                leftLabelRefs.current[index] = node;
              }}
              className="absolute left-0 top-0 tabular-nums tracking-[-0.085em] text-[#111210] will-change-[transform,opacity,filter]"
            >
              {value}
            </span>
          ))}
        </div>

        <div
          ref={rightRailRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-y-0 right-0 w-[34%] min-w-[92px] max-w-[220px] overflow-visible"
        >
          <div ref={rightCircleRef} className="absolute box-border rounded-full border-solid border-[#20211f]/8" />
          {RIGHT_VALUES.map((value, index) => (
            <span
              key={value}
              ref={(node) => {
                rightLabelRefs.current[index] = node;
              }}
              className="absolute left-0 top-0 tabular-nums tracking-[-0.085em] text-[#111210] will-change-[transform,opacity,filter]"
            >
              {value}
            </span>
          ))}
        </div>

        <div className="absolute inset-x-[22%] inset-y-8 z-10 sm:inset-x-[20%]">
          {chapters.map((chapter, index) => (
            <article
              key={chapter.title}
              ref={(node) => {
                chapterRefs.current[index] = node;
              }}
              className="absolute left-1/2 top-1/2 w-full max-w-xl px-4 text-center will-change-[transform,opacity,filter]"
            >
              <h2 className="fraunces mx-auto max-w-lg text-balance text-[17px] font-light leading-[1.04] tracking-[-0.035em] sm:text-3xl">
                {chapter.title}
              </h2>
            </article>
          ))}
        </div>

        </div>
      </div>
    </section>
  );
}

Usage

chrono-story.tsx
"use client";

import { ChronoRails } from "@/components/interior/chrono-rails";

export function Story() {
  return <ChronoRails />;
}

Props

chaptersbuilt in
readonly ChronoRailChapter[]

Content revealed through the scroll sequence.

classNameundefined
string

Classes applied to the local scroll viewport.

styleundefined
CSSProperties

Inline styles applied to the local scroll viewport.

The component uses a compact local scroll timeline. Its visible frame stays h-72 / sm:h-96 while the two orbital scales and central chapters follow the frame's own scroll progress.

For inspiration: tryclucky.com.