Chrono Rails
Two fixed clocks measure one quiet journey through the page.
chrono-railsscroll to explore
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
stringClasses applied to the local scroll viewport.
styleundefined
CSSPropertiesInline 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.