# Dot Rail: Reading Progress

> A long read whose rail sits at how far you've scrolled. Scroll fast and the tail stretches out behind it.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/dot-rail-reading-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/dot-rail-reading-demo
- Screenshot: https://shadcn.exchange/shot/keyframeui/dot-rail-reading-demo/light.png
- Registry item: https://keyframeui.com/r/dot-rail-reading-demo.json
- Categories: navigation, progress, canvas
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/keyframeui/dot-rail-reading-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/dot-rail.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/dot-rail#dot-rail-reading-demo

## Files

### `registry/demos/dot-rail-reading-demo.tsx`

```tsx
"use client";

import * as React from "react";

import { DotRail } from "@/components/ui/dot-rail";

const MINUTES = 6; // reading time for the whole piece

const paragraphs = [
  "Ride the N4 out of Varn after midnight and, somewhere past the fish market, the bus does something no timetable explains. It slows, swings wide around a corner nobody needs to swing wide around, and runs the next half mile down the middle of the road, as if it were keeping to a line painted there.",
  "It is. The paint has been gone for fifty years, but the line hasn't. The N4 follows the old number 4 tram, rail for rail, because when the trams stopped in 1974 the city gave their routes to the buses and never took them back. The drivers learned the corners from the tram men, and the next drivers learned them from those.",
  "You can read the whole network this way. The night lines bend where the depots were. They stop at shelters that were tram halts, under iron roofs too long for a single bus. The 11 still waits two minutes at Harbour Gate, which made sense when the tram had to change its pole there and makes none now.",
];

const paragraphsAfter = [
  "Planners have tried to straighten them. In 2009 a consultancy redrew the night map from scratch, with routes chosen by where people actually live now. It was faster on paper by eleven minutes a trip. Ridership fell by a fifth in the first winter, and most of the old lines were back by spring.",
  "The drivers had a theory about that, and it holds up. A night bus isn't only carrying people home; it's a lit room moving through a dark city on a path everyone already knows. People time their walk to the stop by the old tram bell at St. Anne's. Bakers set their ovens by the first 4 going past. A faster route that nobody can picture is a worse route at two in the morning.",
  "So the city stopped fighting it. The new shelters going up along the 4 are built to the old halt length, with room for a tram that isn't coming, and the depot at Calder Street, the last one standing, is being turned into a bus garage that keeps its rails in the floor.",
  "Ride it once with this in mind and you won't unsee it: the long, unhurried bend at the market, the pause at Harbour Gate, the stretch down the middle of the road. The bus isn't lost. It's remembering.",
];

export function DotRailReadingDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const [left, setLeft] = React.useState(MINUTES);

  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    let raf = 0;
    const read = () => {
      raf = 0;
      const max = el.scrollHeight - el.clientHeight;
      const p = max > 0 ? el.scrollTop / max : 0;
      setLeft(Math.ceil((1 - p) * MINUTES - 0.05));
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(read);
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      el.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(raf);
    };
  }, []);

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-[#0b0b0d]/10 bg-[#fafaf9] text-[#0b0b0d] [--dot-rail-accent:#ff5a1f] dark:border-white/10 dark:bg-[#07080c] dark:text-[#f4f4f6] dark:[--dot-rail-accent:#ff6a2b]">
      <div ref={scrollRef} className="@container relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <div className="sticky top-0 z-10 bg-[#fafaf9] px-5 pt-3.5 dark:bg-[#07080c] @[40rem]:px-8">
          <div className="flex items-center justify-between gap-4 font-mono text-[11px] tracking-[0.16em] uppercase">
            <span className="truncate">
              Tram <em className="font-serif text-[13px] tracking-normal normal-case">&amp;</em> Tide · No. 14
            </span>
            <span className="shrink-0 text-[#0b0b0d]/60 tabular-nums dark:text-[#f4f4f6]/60">
              {left <= 0 ? "Finished" : `${left} min left`}
            </span>
          </div>
          <DotRail follow="scroll" containerRef={scrollRef} aria-label="Reading progress" className="mt-2.5 mb-px" />
        </div>

        <article className="mx-auto max-w-[36rem] px-5 pt-10 pb-24 @[40rem]:px-8 @[40rem]:pt-14">
          <h2 className="font-serif text-4xl leading-[1.05] tracking-tight text-balance @[40rem]:text-5xl">
            The night bus <em className="text-[#2747ff] dark:text-[#8292ff]">remembers</em> the tram
          </h2>
          <p className="mt-5 text-[15px] text-[#0b0b0d]/60 dark:text-[#f4f4f6]/60">
            By Ines Okafor · Photographs by the author
          </p>

          <div className="mt-10 space-y-5 text-[16px] leading-[1.7] text-[#0b0b0d]/85 dark:text-[#f4f4f6]/85">
            {paragraphs.map((p) => (
              <p key={p.slice(0, 24)}>{p}</p>
            ))}
            <blockquote className="my-10 border-l border-current/20 pl-5 font-serif text-2xl leading-snug text-[#0b0b0d] italic dark:text-[#f4f4f6]">
              “A faster route that nobody can picture is a worse route at two in the morning.”
            </blockquote>
            <h3 className="pt-4 font-serif text-2xl tracking-tight text-[#0b0b0d] dark:text-[#f4f4f6]">Eleven minutes faster</h3>
            {paragraphsAfter.map((p) => (
              <p key={p.slice(0, 24)}>{p}</p>
            ))}
          </div>

          <p className="mt-12 font-mono text-[11px] tracking-[0.18em] text-[#0b0b0d]/50 uppercase dark:text-[#f4f4f6]/50">
            End · Filed from the 4, 01:40
          </p>
        </article>
      </div>
    </div>
  );
}
```
