For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Scroll Rail

A tick rail that mirrors a long scroll region: width carries distance from the reading line, colour carries focus. Hover a tick to preview the turn, click to jump to it.

Are Metrics better than Logs here?

Both measure the same session, but they answer different questions. Metrics are the timeline: frames, long tasks, layout shifts. Logs are the narration: what the code decided and why.

Which one do I pick for ongoing collection?

For continuous collection and watching the shape of performance over a release, metrics win — they are cheap, uniform and comparable. Digging into one user's jank is where logs win.

Can I keep the sample rate low?

Yes. A counter per frame is a few bytes; a log line is a thousand times that, and the interesting ones are rare. Sampling the long frames keeps the cost flat.

So should every frame carry a trace id?

Emit a metric per dropped frame, then attach the last N trace summaries to the payload when the frame is slow enough to be worth explaining.

What goes in the dashboard?

Ninety-fifth percentile of frame time, plus a count of frames over 50ms. Averages hide the jank you are looking for.

Do I still need logs at all?

Keep both, but never make the logs the source of truth — they are for explaining a number that already moved, not for producing it.

How should I break them down?

Group by route and by device class. The interesting failures are concentrated in one of the two, and the aggregate flattens them into noise.

Where do percentiles get computed?

Percentiles from a histogram, computed on the client at flush time. Shipping raw samples off the device is the expensive part.

What should alert?

Alert on the count of slow frames in a window, not on the percentile. A count is stable enough to page on, and a percentile moves with the traffic mix.

How long do we keep them?

Retain the metrics for a release cycle and the logs for a week. Both are dirty after that: routes come and go, and the log format drifts.

Where do I start?

Start with frame time and dropped frames. Add memory only once you have a page that fails with it, otherwise it is a number nobody trusts.

"use client";

import { useReducedMotion } from "motion/react";

Installation

$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/scroll-rail.json

Usage

The rail draws; the hooks measure. Focus is one continuous number — 0 is the first tick, items.length - 1 the last — so anything that can produce that number can drive the rail.

import { useRef } from "react";
 
import { ScrollRail, ScrollRailPreview } from "@/components/scroll-rail";
import { useMessageFocus } from "@/lib/hooks/use-scroll-focus";
 
export function Transcript() {
  const scroller = useRef<HTMLDivElement>(null);
  const nodes = useRef<(HTMLElement | null)[]>([]);
  const focus = useMessageFocus(scroller, nodes);
 
  return (
    <div className="flex gap-4">
      <ScrollRail
        focus={focus}
        label="Transcript"
        items={messages.map((message) => ({
          id: message.id,
          label: message.question,
          preview: (
            <ScrollRailPreview title={message.question}>
              {message.answer}
            </ScrollRailPreview>
          ),
        }))}
        onSelect={(index) =>
          nodes.current[index]?.scrollIntoView({ block: "center" })
        }
      />
      <div ref={scroller} className="h-96 overflow-y-auto">
        {messages.map((message, index) => (
          <article
            key={message.id}
            ref={(node) => {
              nodes.current[index] = node;
            }}
          >
            {message.answer}
          </article>
        ))}
      </div>
    </div>
  );
}

Focus

Two focus modes ship as hooks, and both return a motion value — the rail never re-renders React while you scroll, it only writes styles.

ModeHookReading line
ItemuseMessageFocus(scroller, nodes, { offset })Measures every item and puts the reading line on whichever one it lands in. Focus is interpolated between neighbouring item centres, so the peak glides instead of stepping.
ProgressuseScrollProgress(scroller, { count })Pure scrolled distance, mapped across the tick count. No measurement, no reading line.

offset places that line inside the viewport: 0 is the top edge, 0.5 the centre, 1 the bottom edge. A transcript usually reads at the centre; a document with a sticky header often reads a third of the way down.

The ends of the scroll are anchors of their own, because a reading line in the middle of the viewport can never reach the centre of an item sitting closer to the end of the content than half a viewport. Without them the first and last ticks would be unreachable: scrolled all the way down, the rail would still point a tick or two short of the end. Item mode also works when the container does not scroll at all — the reading line simply lands on the item it lands on.

Metrics beat logs for ongoing collection

The rail reads the scroll container, not the page: one tick per message, and the reading line sits at the centre of the viewport. Hover a tick to preview the turn, click to jump to it.

Logs still explain a single bad session

Logs stay for the why. Metrics answer what and how often.

Sampling keeps the cost flat

A counter per frame costs a few bytes.

Alert on the count, not the percentile

Counts are stable enough to page on.

Start with two numbers

Frame time and dropped frames first.

Compute percentiles on the device

Percentiles come from a client-side histogram.

Retention follows the release cycle

A release cycle for metrics, a week for logs.

Two dimensions are enough

Group by route and device class.

Anatomy

One tick per item, left-aligned, pitch apart. Two Gaussian falloffs away from the focus decide how a tick looks, and they are deliberately different widths:

  • Width is the read-out. spread sets how far the lengthening reaches, in ticks. Between minLength and maxLength, which default to 10 and 28.
  • Colour is the pointer. highlight sets how far the brightening reaches — keep it tighter than spread so one tick reads as the position and its neighbours only hint at direction. minOpacity is how faint the far end gets.

Colour is bg-current at varying opacity, so the rail inherits text-foreground and any colour you put on it: className="text-primary" recolours the whole rail without touching a single prop.

PropDefaultMeaning
pitch10Vertical distance between ticks, in px
thickness2Tick thickness, in px
minLength / maxLength10 / 28Resting and focused tick length, in px
spread1.35Width falloff, in ticks
highlight0.45Colour falloff, in ticks
minOpacity0.2Opacity at the far end
side"left"Which edge the rail sits on; flips the grow direction and the popover side

Interaction

Interaction is optional. With an onSelect or any preview the rail becomes a control; with neither it is a read-out, hidden from assistive tech and from the pointer.

Hovering or focusing a tick lengthens it, brightens it, and opens its preview in a popover anchored to the tick. Both waits are deliberate: a preview opens only after the pointer has rested on a tick, so sweeping down the rail opens nothing, and it survives for a beat after the pointer leaves — long enough to cross the gap into the panel itself. Previewing is gated behind useHoverCapable, and only keyboard focus (:focus-visible) holds a preview open, so a mouse click never pins one to a tick you have moved away from.

The rail is one tab stop, not one per message: arrows, Home and End move between ticks, and the tick you land on keeps aria-current. Every tick names itself ("3 of 11: question text") for screen readers. Bring your own reduced-motion handling when you scroll on select — the demo switches behavior from smooth to auto.

Component source

scroll-rail.tsx
"use client";

import {
  animate,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
  useTransform,
} from "motion/react";
import type { MotionValue } from "motion/react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";

import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";

import type {
  ScrollRailFocus,
  ScrollRailItem,
  ScrollRailPreviewProps,
  ScrollRailProps,
} from "./types";

export type {
  ScrollRailFocus,
  ScrollRailItem,
  ScrollRailPreviewProps,
  ScrollRailProps,
} from "./types";

/** The rail has one shape: a Gaussian falloff away from the focus. `spread` sets how
 *  far it reaches for the width, `highlight` how far it reaches for the colour. */
const bell = (distance: number, spread: number) =>
  Math.exp(-((distance / spread) ** 2));

/** Reading a preview is deliberate: sweeping down the rail must not open one popover
 *  per tick. A tick has to be held before its preview opens, and it keeps the preview
 *  for a beat after the pointer leaves — long enough to cross the gap into the panel. */
const OPEN_DELAY = 90;
const CLOSE_DELAY = 180;

interface RailConfig {
  highlight: number;
  maxLength: number;
  minLength: number;
  minOpacity: number;
  origin: "left" | "right";
  pitch: number;
  popoverSide: "left" | "right";
  spread: number;
  thickness: number;
}

/**
 * Focus is either a plain number or a motion value. Numbers are copied into a motion
 * value so both kinds follow one code path; the spring is what turns a scroll
 * position into a glide.
 */
function useFocusTrack(focus: ScrollRailFocus | undefined) {
  const reduce = useReducedMotion() ?? false;
  const fallback = useMotionValue(typeof focus === "number" ? focus : 0);

  useEffect(() => {
    if (typeof focus === "number") {
      fallback.set(focus);
    }
  }, [fallback, focus]);

  const source =
    typeof focus === "number" || focus === undefined ? fallback : focus;
  const spring = useSpring(source, SPRING_LAYOUT);

  return reduce ? source : spring;
}

interface TickProps {
  ariaLabel: string;
  canHover: boolean;
  config: RailConfig;
  current: boolean;
  hovered: boolean;
  index: number;
  item: ScrollRailItem;
  onHover: (index: number, over: boolean) => void;
  onRegister: (index: number, node: HTMLButtonElement | null) => void;
  onSelect?: (index: number, item: ScrollRailItem) => void;
  tabIndex: number;
  track: MotionValue<number>;
}

const Tick = memo(function Tick({
  ariaLabel,
  canHover,
  config,
  current,
  hovered,
  index,
  item,
  onHover,
  onRegister,
  onSelect,
  tabIndex,
  track,
}: TickProps) {
  const reduce = useReducedMotion() ?? false;
  const lift = useMotionValue(0);
  // A click focuses a button too. Only keyboard focus should hold a preview open,
  // otherwise the panel Radix restores focus to reopens itself in a loop.
  const keyboard = useRef(false);

  useEffect(() => {
    if (reduce) {
      lift.set(hovered ? 1 : 0);
      return;
    }
    const controls = animate(lift, hovered ? 1 : 0, SPRING_SWAP);
    return () => controls.stop();
  }, [hovered, lift, reduce]);

  // Width and colour are two falloffs of the same distance, composed with the hover
  // lift. Both are written straight to the DOM: no state, no re-render per frame.
  const scaleX = useTransform([track, lift], ([focus, lifted]: number[]) => {
    const distance = Math.abs(index - focus);
    const length =
      config.minLength +
      (config.maxLength - config.minLength) * bell(distance, config.spread);
    return (length + (config.maxLength - length) * lifted) / config.maxLength;
  });

  const opacity = useTransform([track, lift], ([focus, lifted]: number[]) => {
    const distance = Math.abs(index - focus);
    const rest =
      config.minOpacity +
      (1 - config.minOpacity) * bell(distance, config.highlight);
    return rest + (1 - rest) * lifted;
  });

  const trigger = (
    <button
      aria-current={current ? "true" : undefined}
      aria-label={ariaLabel}
      className="group flex h-full w-full cursor-pointer items-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
      onClick={() => onSelect?.(index, item)}
      onBlur={() => {
        if (keyboard.current) {
          keyboard.current = false;
          onHover(index, false);
        }
      }}
      onFocus={(event) => {
        keyboard.current = event.currentTarget.matches(":focus-visible");
        if (keyboard.current) {
          onHover(index, true);
        }
      }}
      onPointerEnter={canHover ? () => onHover(index, true) : undefined}
      onPointerLeave={canHover ? () => onHover(index, false) : undefined}
      ref={(node) => onRegister(index, node)}
      tabIndex={tabIndex}
      type="button"
    >
      <motion.span
        aria-hidden="true"
        className="block bg-current"
        style={{
          height: config.thickness,
          opacity,
          scaleX,
          transformOrigin: config.origin,
          width: config.maxLength,
        }}
      />
    </button>
  );

  return (
    <li
      className="flex items-center"
      data-slot="scroll-rail-tick"
      style={{ height: config.pitch }}
    >
      {item.preview ? (
        <Popover
          onOpenChange={(open) => {
            if (!open) {
              onHover(index, false);
            }
          }}
          open={canHover && hovered}
        >
          <PopoverTrigger asChild>{trigger}</PopoverTrigger>
          <PopoverContent
            align="center"
            className="w-72 p-3"
            data-slot="scroll-rail-preview"
            onCloseAutoFocus={(event) => event.preventDefault()}
            onOpenAutoFocus={(event) => event.preventDefault()}
            onPointerEnter={() => onHover(index, true)}
            onPointerLeave={() => onHover(index, false)}
            side={config.popoverSide}
            sideOffset={8}
          >
            {item.preview}
          </PopoverContent>
        </Popover>
      ) : (
        trigger
      )}
    </li>
  );
});

/** The standard preview body: the question that opened the turn, then the gist of
 *  the answer. */
export const ScrollRailPreview = ({
  title,
  children,
  className,
}: ScrollRailPreviewProps) => (
  <div className={cn("space-y-1.5 text-left", className)}>
    <p className="font-medium text-sm leading-snug">{title}</p>
    <p className="text-muted-foreground text-sm leading-relaxed">{children}</p>
  </div>
);

export function ScrollRail({
  items,
  focus,
  onSelect,
  side = "left",
  label = "Scroll position",
  pitch = 10,
  thickness = 2,
  minLength = 10,
  maxLength = 28,
  spread = 1.35,
  highlight = 0.45,
  minOpacity = 0.2,
  className,
}: ScrollRailProps) {
  const count = items.length;
  const track = useFocusTrack(focus);
  const canHover = useHoverCapable();
  const nodes = useRef<(HTMLButtonElement | null)[]>([]);

  // Hover is one index for the whole rail, so two previews can never stack. Opening
  // waits for the pointer to settle and closing waits for it to come back — a stale
  // timer can only ever clear the index it was started for.
  const pendingOpen = useRef<ReturnType<typeof setTimeout> | null>(null);
  const pendingClose = useRef<ReturnType<typeof setTimeout> | null>(null);
  const [hovered, setHovered] = useState<number | null>(null);

  const [cursor, setCursor] = useState<number | null>(null);
  const [active, setActive] = useState(() => Math.round(track.get()));

  useMotionValueEvent(track, "change", (value) => {
    const next = Math.min(count - 1, Math.max(0, Math.round(value)));
    setActive((previous) => (previous === next ? previous : next));
  });

  const clearTimers = useCallback(() => {
    if (pendingOpen.current !== null) {
      clearTimeout(pendingOpen.current);
      pendingOpen.current = null;
    }
    if (pendingClose.current !== null) {
      clearTimeout(pendingClose.current);
      pendingClose.current = null;
    }
  }, []);

  useEffect(() => clearTimers, [clearTimers]);

  const onHover = useCallback(
    (index: number, over: boolean) => {
      clearTimers();

      if (over) {
        pendingOpen.current = setTimeout(() => {
          pendingOpen.current = null;
          setHovered(index);
        }, OPEN_DELAY);
        return;
      }

      pendingClose.current = setTimeout(() => {
        pendingClose.current = null;
        setHovered((previous) => (previous === index ? null : previous));
      }, CLOSE_DELAY);
    },
    [clearTimers]
  );

  const onRegister = useCallback(
    (index: number, node: HTMLButtonElement | null) => {
      nodes.current[index] = node;
    },
    []
  );

  const moveTo = useCallback(
    (next: number) => {
      const bounded = Math.min(count - 1, Math.max(0, next));
      setCursor(bounded);
      nodes.current[bounded]?.focus();
    },
    [count]
  );

  const onKeyDown = (event: React.KeyboardEvent<HTMLOListElement>) => {
    const from = cursor ?? active;

    switch (event.key) {
      case "ArrowDown":
      case "ArrowRight": {
        event.preventDefault();
        moveTo(from + 1);
        break;
      }
      case "ArrowUp":
      case "ArrowLeft": {
        event.preventDefault();
        moveTo(from - 1);
        break;
      }
      case "Home": {
        event.preventDefault();
        moveTo(0);
        break;
      }
      case "End": {
        event.preventDefault();
        moveTo(count - 1);
        break;
      }
      default: {
        break;
      }
    }
  };

  const onBlur = (event: React.FocusEvent<HTMLOListElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
      setCursor(null);
    }
  };

  const config = useMemo<RailConfig>(
    () => ({
      highlight,
      maxLength,
      minLength,
      minOpacity,
      origin: side === "right" ? "right" : "left",
      pitch,
      popoverSide: side === "right" ? "left" : "right",
      spread,
      thickness,
    }),
    [
      highlight,
      maxLength,
      minLength,
      minOpacity,
      pitch,
      side,
      spread,
      thickness,
    ]
  );

  if (count === 0) {
    return null;
  }

  // Without a handler and without previews the rail is a read-out, not a control:
  // hidden from assistive tech, out of the tab order, out of the way of the pointer.
  const interactive = Boolean(onSelect) || items.some((item) => item.preview);

  return (
    <nav
      aria-hidden={interactive ? undefined : true}
      aria-label={interactive ? label : undefined}
      className={cn(
        "text-foreground shrink-0 select-none",
        !interactive && "pointer-events-none",
        className
      )}
      data-slot="scroll-rail"
      style={{ width: maxLength }}
    >
      <ol className="m-0 list-none p-0" onBlur={onBlur} onKeyDown={onKeyDown}>
        {items.map((item, index) => (
          <Tick
            ariaLabel={`${index + 1} of ${count}: ${item.label ?? "Untitled"}`}
            canHover={canHover}
            config={config}
            current={index === active}
            hovered={hovered === index}
            index={index}
            item={item}
            key={item.id}
            onHover={onHover}
            onRegister={onRegister}
            onSelect={onSelect}
            tabIndex={index === (cursor ?? active) ? 0 : -1}
            track={track}
          />
        ))}
      </ol>
    </nav>
  );
}