# Story Rail

> Horizontal story rail with unread gradient rings, drag-free skim scrolling, optional arrows, an add-story tile, and scroll-aware edge fades.

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

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/story-rail
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/story-rail-light-63ace85bb88aa6cb.png
- Registry item: https://ui.ilinxa.com/r/story-rail.json
- Categories: data
- Listed: 2026-10-13, source last changed 2026-10-23

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ilinxa/story-rail.json
```

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

## Dependencies

- npm packages: `embla-carousel-react`, `lucide-react`
- Registry items: `avatar`

## Files

### `src/registry/components/data/story-rail/story-rail.tsx`

```tsx
"use client";

import {
  memo,
  useCallback,
  useEffect,
  useId,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from "react";
import useEmblaCarousel from "embla-carousel-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import {
  DEFAULT_STORY_RAIL_LABELS,
  type ResolvedStoryRailLabels,
  type StoryRailHandle,
  type StoryRailProps,
  type StoryRailItem,
} from "./types";
import { useStoryRailState } from "./hooks/use-story-rail-state";
import {
  useFinePointer,
  useReducedMotion,
  useResolvedDirection,
} from "./hooks/use-rail-environment";
import { StoryThumbnail } from "./parts/story-thumbnail";
import { StoryRailLabelsContext } from "./parts/story-rail-context";

interface StoryRailInnerProps extends StoryRailProps {
  ref?: React.Ref<StoryRailHandle>;
}

const EMPTY: StoryRailItem[] = [];

function StoryRailInner(props: StoryRailInnerProps) {
  const {
    items: itemsProp,
    defaultItems,
    onItemsChange,
    leading,
    framed = true,
    subscribe,
    onSubscribeDelta,
    onItemClick,
    onPreviewError,
    arrows = "none",
    dir: dirProp,
    linkComponent,
    getHref,
    renderThumbnail,
    emptyState,
    labels: labelsProp,
    className,
    thumbnailClassName,
    ref,
  } = props;

  // Adapter for internal parts (StoryThumbnail + renderThumbnail slot) that
  // still use a positional (item, index) shape. Maps to the public object-
  // shape `onItemClick({ item, index })`.
  const handlePartItemClick: ((item: StoryRailItem, index: number) => void) | undefined =
    onItemClick
      ? (item, index) => onItemClick({ item, index })
      : undefined;

  // Partial override merged over the defaults. A key passed as `undefined`
  // (e.g. a conditional i18n entry) keeps its default instead of erasing it.
  const labels = useMemo<ResolvedStoryRailLabels>(() => {
    const merged = { ...DEFAULT_STORY_RAIL_LABELS };
    if (labelsProp) {
      for (const key of Object.keys(labelsProp) as (keyof typeof merged)[]) {
        const value = labelsProp[key];
        if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
      }
    }
    return merged;
  }, [labelsProp]);

  const { items, dispatch } = useStoryRailState({
    initialItems: defaultItems ?? itemsProp ?? EMPTY,
    items: itemsProp,
    onItemsChange,
    subscribe,
    onSubscribeDelta,
  });

  // `items` without `onItemsChange` keeps the pre-0.4 read-once behaviour.
  // Disclose it once per mount so live data is never silently frozen.
  const warnedLegacyRef = useRef(false);
  useEffect(() => {
    if (process.env.NODE_ENV === "production" || warnedLegacyRef.current) return;
    if (itemsProp !== undefined && onItemsChange === undefined) {
      warnedLegacyRef.current = true;
      console.warn(
        defaultItems !== undefined
          ? "[story-rail] `items` is ignored: `defaultItems` seeds an uncontrolled rail. Remove `items`, or add `onItemsChange` to make `items` controlled."
          : "[story-rail] `items` was passed without `onItemsChange`, so it is read once on mount and later changes are ignored. " +
              "Pass `defaultItems` for an uncontrolled rail, or add `onItemsChange` to make `items` controlled.",
      );
    }
  }, [itemsProp, onItemsChange, defaultItems]);

  const sectionRef = useRef<HTMLElement>(null);
  const direction = useResolvedDirection(sectionRef, dirProp);
  const finePointer = useFinePointer();
  const reducedMotion = useReducedMotion();

  const emblaOptions = useMemo(
    () => ({
      align: "start" as const,
      containScroll: "trimSnaps" as const,
      dragFree: true,
      direction,
    }),
    [direction],
  );
  const [emblaRef, emblaApi] = useEmblaCarousel(emblaOptions);

  // Scroll affordances. Initial values match what the server renders before
  // Embla exists: at the start, so nothing to go back to; assume more ahead.
  const [canPrev, setCanPrev] = useState(false);
  const [canNext, setCanNext] = useState(true);
  const prevButtonRef = useRef<HTMLButtonElement>(null);
  const nextButtonRef = useRef<HTMLButtonElement>(null);
  useEffect(() => {
    if (!emblaApi) return;
    const update = () => {
      const overflow = emblaApi.scrollSnapList().length > 1;
      const progress = emblaApi.scrollProgress();
      const prev = overflow && progress > 0.001;
      const next = overflow && progress < 0.999;
      // An arrow that unmounts while focused would drop focus to <body>:
      // hand it to the other arrow, or to the first tile when both go.
      const active = typeof document === "undefined" ? null : document.activeElement;
      const losing =
        (!next && active !== null && active === nextButtonRef.current) ||
        (!prev && active !== null && active === prevButtonRef.current);
      if (losing) {
        const fallback =
          (next ? nextButtonRef.current : null) ??
          (prev ? prevButtonRef.current : null) ??
          emblaApi.slideNodes()[0]?.querySelector<HTMLElement>("button, a[href]");
        fallback?.focus();
      }
      setCanPrev(prev);
      setCanNext(next);
    };
    update();
    emblaApi.on("init", update);
    emblaApi.on("reInit", update);
    emblaApi.on("scroll", update);
    emblaApi.on("select", update);
    emblaApi.on("resize", update);
    return () => {
      emblaApi.off("init", update);
      emblaApi.off("reInit", update);
      emblaApi.off("scroll", update);
      emblaApi.off("select", update);
      emblaApi.off("resize", update);
    };
  }, [emblaApi]);

  // Slide index = item index + 1 when `leading` occupies slide 0. With
  // `containScroll: "trimSnaps"` the trailing slides share the last snap.
  const leadingOffset = leading ? 1 : 0;
  const scrollToSlide = useCallback(
    (slide: number) => {
      if (!emblaApi) return;
      const last = emblaApi.scrollSnapList().length - 1;
      emblaApi.scrollTo(Math.max(0, Math.min(slide, last)), reducedMotion);
    },
    [emblaApi, reducedMotion],
  );

  const scrollByPage = useCallback(
    (step: 1 | -1) => {
      if (!emblaApi) return;
      const inView = emblaApi.slidesInView();
      if (inView.length === 0) {
        if (step > 0) emblaApi.scrollNext(reducedMotion);
        else emblaApi.scrollPrev(reducedMotion);
        return;
      }
      const first = Math.min(...inView);
      const lastInView = Math.max(...inView);
      const span = Math.max(1, lastInView - first);
      // Forward: the last (partly hidden) tile becomes the first one.
      scrollToSlide(step > 0 ? first + span : first - span);
    },
    [emblaApi, reducedMotion, scrollToSlide],
  );

  // Stable ref for the imperative handle.
  const itemsRef = useRef<StoryRailItem[]>(items);
  useEffect(() => {
    itemsRef.current = items;
  });

  useImperativeHandle(
    ref,
    () => ({
      scrollTo: (index: number) => scrollToSlide(index + leadingOffset),
      getCurrentItems: () => itemsRef.current,
      reset: (next: StoryRailItem[]) => dispatch({ kind: "reset", next }),
      dispatch,
      markViewed: (itemId: string) =>
        dispatch({ kind: "viewed", itemId }),
    }),
    [scrollToSlide, leadingOffset, dispatch],
  );

  const rootId = useId();
  const showEmpty = items.length === 0 && !subscribe && !leading;
  const showRail = !showEmpty;
  const showArrows =
    arrows === "always" || (arrows === "fine-pointer" && finePointer);

  const fadeFrom = framed ? "from-card" : "from-background";
  const arrowClass =
    "absolute top-(--story-rail-arrow-top,3.875rem) z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background/90 text-foreground shadow-md backdrop-blur-sm motion-safe:transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";

  return (
    <StoryRailLabelsContext.Provider value={labels}>
      <section
        ref={sectionRef}
        role="region"
        aria-label={labels.railLabel}
        dir={dirProp}
        className={cn(
          "relative overflow-hidden",
          framed ? "rounded-xl border border-border/50 bg-card p-4" : "",
          className,
        )}
      >
        {showRail ? (
          <div className="relative">
            {/* Edge fades render only with items (Q-P22) and only on a side
                the rail can scroll toward, so at rest the first tile (and its
                focus ring) is never covered. Width is a CSS variable. */}
            <div
              aria-hidden="true"
              data-slot="story-rail-fade-start"
              data-visible={canPrev ? "true" : "false"}
              className={cn(
                "pointer-events-none absolute inset-y-0 start-0 z-10 w-(--story-rail-fade-width,3rem) bg-linear-to-r to-transparent motion-safe:transition-opacity rtl:bg-linear-to-l",
                fadeFrom,
                canPrev ? "opacity-100" : "opacity-0",
              )}
            />
            <div
              aria-hidden="true"
              data-slot="story-rail-fade-end"
              data-visible={canNext ? "true" : "false"}
              className={cn(
                "pointer-events-none absolute inset-y-0 end-0 z-10 w-(--story-rail-fade-width,3rem) bg-linear-to-l to-transparent motion-safe:transition-opacity rtl:bg-linear-to-r",
                fadeFrom,
                canNext ? "opacity-100" : "opacity-0",
              )}
            />
            {showArrows && canPrev ? (
              <button
                ref={prevButtonRef}
                type="button"
                aria-label={labels.previousLabel}
                onClick={() => scrollByPage(-1)}
                className={cn(arrowClass, "start-1")}
              >
                <ChevronLeft aria-hidden="true" className="size-4 rtl:-scale-x-100" />
              </button>
            ) : null}
            <div className="overflow-hidden" ref={emblaRef}>
              {/* py-1 keeps the 2px focus ring inside the clipping viewport. */}
              <div className="flex gap-3 px-2 py-1">
                {leading ? <div className="shrink-0">{leading}</div> : null}
                {items.map((item, index) => (
                  <div key={item.id} className="shrink-0">
                    {renderThumbnail
                      ? renderThumbnail(item, !!item.hasUnread, {
                          index,
                          onClick: () => handlePartItemClick?.(item, index),
                          baseId: `${rootId}-${item.id}`,
                          showRing: !!item.hasUnread && !item.muted,
                        })
                      : (
                          <StoryThumbnail
                            item={item}
                            index={index}
                            baseId={`${rootId}-${item.id}`}
                            onItemClick={handlePartItemClick}
                            onPreviewError={onPreviewError}
                            getHref={getHref}
                            linkComponent={linkComponent}
                            labels={labels}
                            className={thumbnailClassName}
                          />
                        )}
                  </div>
                ))}
              </div>
            </div>
            {showArrows && canNext ? (
              <button
                ref={nextButtonRef}
                type="button"
                aria-label={labels.nextLabel}
                onClick={() => scrollByPage(1)}
                className={cn(arrowClass, "end-1")}
              >
                <ChevronRight aria-hidden="true" className="size-4 rtl:-scale-x-100" />
              </button>
            ) : null}
          </div>
        ) : (
          emptyState ?? (
            <p
              role="status"
              className="py-6 text-center text-sm text-muted-foreground"
            >
              {labels.emptyState}
            </p>
          )
        )}
      </section>
    </StoryRailLabelsContext.Provider>
  );
}

const StoryRail = memo(StoryRailInner);
StoryRail.displayName = "StoryRail";

export { StoryRail };
```

### `src/registry/components/data/story-rail/index.ts`

```ts
export { StoryRail } from "./story-rail";
export { AddStoryThumbnail } from "./parts/add-story-thumbnail";
export type { AddStoryThumbnailProps } from "./parts/add-story-thumbnail";

export {
  storyRailReducer,
  useStoryRailState,
  type UseStoryRailStateOptions,
  type UseStoryRailStateResult,
} from "./hooks/use-story-rail-state";

export type {
  StoryRailItem,
  StoryRailProps,
  StoryRailHandle,
  StoryRailLabels,
  ResolvedStoryRailLabels,
  StoryRailArrows,
  StoryRailDelta,
  StoryRailLocalAction,
  ThumbnailRenderHelpers,
  Subscribe,
  Unsubscribe,
} from "./types";

export { DEFAULT_STORY_RAIL_LABELS } from "./types";

// @ilinxa/story-rail 0.4.0
```

### `src/registry/components/data/story-rail/types.ts`

```ts
import type { ElementType, ReactNode } from "react";

export interface StoryRailItem {
  id: string;
  username: string;
  avatar?: string;
  /**
   * Portrait preview shown inside the ring. Optional — when absent (or when it
   * fails to load and `onPreviewError` cannot refresh it) the tile renders an
   * avatar card: the author's avatar centred on `bg-muted`.
   */
  previewImage?: string;
  /** Forwarded to the preview `<img srcSet>`. */
  previewSrcSet?: string;
  /** Forwarded to the preview `<img sizes>`. Defaults to `"80px"` when `previewSrcSet` is set. */
  previewSizes?: string;
  /** Drives ring visual: gradient (unread) vs muted (read). Default false. */
  hasUnread?: boolean;
  /**
   * Muted author — grey ring even when unread, tile + name at reduced opacity.
   * Ordering stays the host's. Default false.
   */
  muted?: boolean;
  /** Optional — exposed for downstream story-viewer wiring. */
  userId?: string;
}

export type StoryRailDelta =
  | { kind: "added"; item: StoryRailItem; position?: "start" | "end" }
  | { kind: "removed"; itemId: string }
  | { kind: "viewed"; itemId: string }
  | {
      kind: "updated";
      itemId: string;
      partial: Partial<StoryRailItem>;
    };

export type Unsubscribe = () => void;
export type Subscribe<T> = (handler: (delta: T) => void) => Unsubscribe;

export interface StoryRailLabels {
  /** Default "Stories". aria-label on the rail's <section role="region">. */
  railLabel?: string;
  /** Default "Add story". Visible label below the AddStoryThumbnail (read from the rail's context). */
  addStoryLabel?: string;
  /** Default "Add story" (contains the visible text — WCAG 2.5.3). aria-label for the AddStoryThumbnail button (read from the rail's context). */
  addStoryAriaLabel?: string;
  /**
   * Accessible name of a thumbnail. Receives the whole item (incl. `hasUnread`
   * and `muted`) so hosts can localize / pluralize. Default:
   * "{username}, muted" / "{username}, unread story" / "{username}, viewed".
   */
  thumbnailAriaLabel?: (item: StoryRailItem) => string;
  /** Default "Previous stories". Accessible name of the previous arrow. */
  previousLabel?: string;
  /** Default "Next stories". Accessible name of the next arrow. */
  nextLabel?: string;
  /** Default "No stories yet." Renders when items.length === 0 AND no leading AND no realtime. */
  emptyState?: string;
}

export type ResolvedStoryRailLabels = Required<StoryRailLabels>;

export const DEFAULT_STORY_RAIL_LABELS: ResolvedStoryRailLabels = {
  railLabel: "Stories",
  addStoryLabel: "Add story",
  addStoryAriaLabel: "Add story",
  thumbnailAriaLabel: (item) =>
    item.muted
      ? `${item.username}, muted`
      : item.hasUnread
        ? `${item.username}, unread story`
        : `${item.username}, viewed`,
  previousLabel: "Previous stories",
  nextLabel: "Next stories",
  emptyState: "No stories yet.",
};

export type StoryRailLocalAction =
  | { kind: "add"; item: StoryRailItem; position?: "start" | "end" }
  | { kind: "remove"; itemId: string }
  | { kind: "viewed"; itemId: string }
  | { kind: "update"; itemId: string; partial: Partial<StoryRailItem> }
  | { kind: "subscribe-delta"; delta: StoryRailDelta }
  | { kind: "reset"; next: StoryRailItem[] };

export interface ThumbnailRenderHelpers {
  index: number;
  onClick: () => void;
  /** Stable id for ARIA wiring inside the slot. */
  baseId: string;
  /**
   * Whether the default tile would show the gradient ring:
   * `hasUnread && !muted`. (`isUnread` is the raw `hasUnread`.)
   */
  showRing: boolean;
}

/**
 * Where the previous / next arrows render.
 * - `"none"` (default) — no arrows; pointer drag (mouse included) and Tab focus scroll the rail.
 *   There is no mouse-wheel / trackpad-swipe scrolling (Embla has none without a plugin).
 * - `"fine-pointer"` — only under `(pointer: fine)` (mouse / trackpad), resolved after mount.
 * - `"always"` — on every pointer type.
 */
export type StoryRailArrows = "none" | "fine-pointer" | "always";

export interface StoryRailProps {
  /**
   * Controlled items when `onItemsChange` is also passed: the prop wins on
   * every render, and every mutation (`markViewed`, `dispatch`, `reset`,
   * subscribe deltas) is reported through `onItemsChange` instead of applied.
   * To discard a reported change, re-render with a fresh array.
   *
   * Passed WITHOUT `onItemsChange` it is read once on mount (the pre-0.4
   * behaviour) and a dev-only warning asks you to choose `defaultItems` or
   * `onItemsChange`. Do not switch between controlled and uncontrolled after mount.
   */
  items?: StoryRailItem[];
  /** Uncontrolled seed, read once on mount. Mutations apply internally. */
  defaultItems?: StoryRailItem[];
  /**
   * Receives the next list for every mutation. With `items` it makes the rail
   * controlled; with `defaultItems` (or no items) the rail stays uncontrolled
   * and only notifies.
   */
  onItemsChange?: (next: StoryRailItem[]) => void;

  /** Optional content rendered before items (typically <AddStoryThumbnail>). */
  leading?: ReactNode;

  /** Wrap the rail in card-style chrome (bg-card + border + rounded). Default true. */
  framed?: boolean;

  /** Realtime delta stream. Identity-stable required (useCallback host-side). */
  subscribe?: Subscribe<StoryRailDelta>;
  onSubscribeDelta?: (delta: StoryRailDelta) => void;

  /**
   * Click handler — object-shape. Host typically opens a story viewer.
   * v0.2 cutover from positional `(item, index)`.
   */
  onItemClick?: (args: { item: StoryRailItem; index: number }) => void;

  /**
   * Called when a preview image fails to load (e.g. an expired signed URL).
   * Resolve a new URL to retry once with it; resolve nothing (or reject) to
   * fall back to the avatar card. A new `previewImage` on the item resets it.
   */
  onPreviewError?: (item: StoryRailItem) => Promise<string | void>;

  /**
   * Previous / next arrows. Default `"none"`. Vertically centred on the default
   * 112px tile; override with `--story-rail-arrow-top` for custom tiles.
   */
  arrows?: StoryRailArrows;

  /** Text direction. Default: the nearest `[dir]` ancestor, else `"ltr"`. */
  dir?: "ltr" | "rtl";

  /** Polymorphic root for the thumbnail when getHref provided. Default `"a"`. */
  linkComponent?: ElementType;
  /** When provided, thumbnail renders as `<linkComponent href={getHref(item)}>` instead of `<button>`. Click also fires onItemClick if both wired. */
  getHref?: (item: StoryRailItem) => string;

  /** Full-takeover render slot for the thumbnail visual. */
  renderThumbnail?: (
    item: StoryRailItem,
    isUnread: boolean,
    helpers: ThumbnailRenderHelpers,
  ) => ReactNode;

  /** Empty-state slot. Wins over labels.emptyState fallback. */
  emptyState?: ReactNode;

  labels?: StoryRailLabels;

  className?: string;
  thumbnailClassName?: string;

  ref?: React.Ref<StoryRailHandle>;
}

export interface StoryRailHandle {
  /** Scroll so the item at `index` (in `items`, not counting `leading`) sits at the start. */
  scrollTo: (index: number) => void;
  getCurrentItems: () => StoryRailItem[];
  reset: (next: StoryRailItem[]) => void;
  dispatch: (action: StoryRailLocalAction) => void;
  /** Mark a story as viewed (sets hasUnread=false). Call from your viewer's onClose. */
  markViewed: (itemId: string) => void;
}
```

### `src/registry/components/data/story-rail/parts/story-thumbnail.tsx`

```tsx
"use client";

import { memo, useEffect, useRef, useState, type ElementType } from "react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { ResolvedStoryRailLabels, StoryRailItem } from "../types";

export interface StoryThumbnailProps {
  item: StoryRailItem;
  index: number;
  baseId: string;
  onItemClick?: (item: StoryRailItem, index: number) => void;
  onPreviewError?: (item: StoryRailItem) => Promise<string | void>;
  getHref?: (item: StoryRailItem) => string;
  linkComponent?: ElementType;
  labels: ResolvedStoryRailLabels;
  className?: string;
}

/** Tile box — the prototype's 80×112 portrait. Also the `<img>` intrinsic size. */
const TILE_W = 80;
const TILE_H = 112;

function initials(name: string): string {
  return (
    name
      .trim()
      .split(/\s+/)
      .map((p) => p[0]?.toUpperCase() ?? "")
      .slice(0, 2)
      .join("") || "?"
  );
}

type PreviewPhase = "primary" | "refreshing" | "retry" | "failed";

interface StoryPreviewProps {
  item: StoryRailItem;
  onPreviewError?: (item: StoryRailItem) => Promise<string | void>;
  /**
   * URLs this tile already obtained from `onPreviewError`. Owned by the
   * (un-keyed) thumbnail so it survives the preview remounting when a host
   * writes a refreshed URL back onto the item — that URL gets ONE attempt and
   * no further refresh, so a backend that keeps minting dead URLs cannot loop.
   */
  refreshed: Set<string>;
}

/**
 * Preview image with an avatar-card fallback. Keyed by `previewImage` at the
 * call site, so a new URL from the host starts over.
 *
 * Flow: no preview → avatar card. Load error → `onPreviewError(item)` (once);
 * a resolved URL is tried once, as a plain `src` (the item's `srcSet` holds the
 * same expired URLs, and browsers prefer `srcset` over `src`); nothing / a
 * rejection / a second error → avatar card. The avatar card also covers the
 * refresh wait (no broken-image icon).
 */
function StoryPreview({ item, onPreviewError, refreshed }: StoryPreviewProps) {
  const initialSrc = item.previewImage || undefined;
  const [src, setSrc] = useState(initialSrc);
  const [phase, setPhase] = useState<PreviewPhase>(
    !initialSrc ? "failed" : refreshed.has(initialSrc) ? "retry" : "primary",
  );
  const imgRef = useRef<HTMLImageElement>(null);
  const aliveRef = useRef(true);
  // Error handling may be entered twice for one failure (the hydration probe
  // and a queued `error` event, or StrictMode effects) — the ref, unlike the
  // `phase` closure, sees the first entry.
  const phaseRef = useRef(phase);
  const itemRef = useRef(item);
  const onPreviewErrorRef = useRef(onPreviewError);
  useEffect(() => {
    itemRef.current = item;
    onPreviewErrorRef.current = onPreviewError;
  });
  useEffect(() => {
    aliveRef.current = true;
    return () => {
      aliveRef.current = false;
    };
  }, []);

  const moveTo = (next: PreviewPhase) => {
    phaseRef.current = next;
    setPhase(next);
  };

  const handleError = () => {
    const current = phaseRef.current;
    if (current === "refreshing" || current === "failed") return;
    const refresh = onPreviewErrorRef.current;
    if (current !== "primary" || !refresh) {
      moveTo("failed");
      return;
    }
    moveTo("refreshing");
    let pending: Promise<string | void>;
    try {
      pending = refresh(itemRef.current);
    } catch {
      moveTo("failed");
      return;
    }
    Promise.resolve(pending).then(
      (next) => {
        if (!aliveRef.current) return;
        if (typeof next === "string" && next) {
          refreshed.add(next);
          setSrc(next);
          moveTo("retry");
        } else {
          moveTo("failed");
        }
      },
      () => {
        if (aliveRef.current) moveTo("failed");
      },
    );
  };

  // An image that failed before hydration never fires React's onError.
  // `complete` with no natural width is ambiguous (broken, or an SVG without
  // intrinsic size), so let `decode()` decide: it rejects only when broken.
  useEffect(() => {
    const img = imgRef.current;
    if (!img || !img.complete || img.naturalWidth !== 0 || !img.currentSrc) return;
    if (typeof img.decode !== "function") return;
    img.decode().then(
      () => {},
      () => {
        if (aliveRef.current) handleError();
      },
    );
    // Mount-only probe; later errors arrive through onError.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  if (phase === "failed" || phase === "refreshing" || !src) {
    return (
      <div
        data-slot="story-rail-avatar-card"
        className="flex h-full w-full items-center justify-center bg-muted"
      >
        <Avatar className="h-12 w-12">
          {item.avatar ? <AvatarImage src={item.avatar} alt="" /> : null}
          <AvatarFallback className="text-sm">
            {initials(item.username)}
          </AvatarFallback>
        </Avatar>
      </div>
    );
  }

  return (
    <img
      ref={imgRef}
      src={src}
      srcSet={phase === "primary" ? item.previewSrcSet : undefined}
      sizes={
        phase === "primary" && item.previewSrcSet
          ? (item.previewSizes ?? `${TILE_W}px`)
          : undefined
      }
      alt=""
      width={TILE_W}
      height={TILE_H}
      loading="lazy"
      decoding="async"
      onError={handleError}
      className="h-full w-full object-cover duration-300 motion-safe:transition-transform motion-safe:group-hover:scale-105"
    />
  );
}

function StoryThumbnailInner({
  item,
  index,
  baseId,
  onItemClick,
  onPreviewError,
  getHref,
  linkComponent,
  labels,
  className,
}: StoryThumbnailProps) {
  // Stable per tile (not per preview): see StoryPreviewProps.refreshed.
  const [refreshed] = useState(() => new Set<string>());
  const isMuted = !!item.muted;
  const showRing = !!item.hasUnread && !isMuted;
  const ariaLabel = labels.thumbnailAriaLabel(item);
  const hasLink = !!getHref;
  const href = hasLink ? getHref!(item) : undefined;
  const handleClick = () => onItemClick?.(item, index);

  const inner = (
    <>
      <div
        data-slot="story-rail-ring"
        data-state={isMuted ? "muted" : showRing ? "unread" : "read"}
        className={cn(
          "rounded-2xl p-0.5 motion-safe:transition-colors",
          showRing
            ? "bg-linear-to-br from-story-ring-from to-story-ring-to"
            : "bg-muted",
          isMuted && "opacity-60",
        )}
      >
        <div className="h-28 w-20 overflow-hidden rounded-[14px] border-2 border-card bg-card">
          <StoryPreview
            key={item.previewImage ?? ""}
            item={item}
            onPreviewError={onPreviewError}
            refreshed={refreshed}
          />
        </div>
      </div>
      <div className={cn("flex items-center gap-1.5", isMuted && "opacity-60")}>
        <Avatar className="h-5 w-5 border border-card">
          {item.avatar ? <AvatarImage src={item.avatar} alt="" /> : null}
          <AvatarFallback className="text-[8px]">
            {initials(item.username)}
          </AvatarFallback>
        </Avatar>
        <span
          id={`${baseId}-name`}
          className="max-w-14 truncate text-center text-xs font-medium"
        >
          {item.username}
        </span>
      </div>
    </>
  );

  const rootClassName = cn(
    "group flex shrink-0 flex-col items-center gap-2 rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
    className,
  );

  if (hasLink && href) {
    const LinkComponent = linkComponent ?? "a";
    return (
      <LinkComponent
        href={href}
        onClick={onItemClick ? handleClick : undefined}
        aria-label={ariaLabel}
        className={rootClassName}
      >
        {inner}
      </LinkComponent>
    );
  }

  return (
    <button
      type="button"
      onClick={handleClick}
      aria-label={ariaLabel}
      className={rootClassName}
    >
      {inner}
    </button>
  );
}

export const StoryThumbnail = memo(StoryThumbnailInner);
StoryThumbnail.displayName = "StoryThumbnail";
```

### `src/registry/components/data/story-rail/parts/add-story-thumbnail.tsx`

```tsx
"use client";

import { memo, useState } from "react";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { DEFAULT_STORY_RAIL_LABELS } from "../types";
import { useStoryRailLabels } from "./story-rail-context";

export interface AddStoryThumbnailProps {
  /** Avatar shown at 50% opacity inside the dashed-border placeholder. */
  userAvatar?: string;
  onClick?: () => void;
  /**
   * Visible label below. Default: the enclosing rail's `labels.addStoryLabel`
   * (when rendered in `<StoryRail leading>`), else "Add story".
   */
  label?: string;
  /**
   * aria-label on the button. Default: the enclosing rail's
   * `labels.addStoryAriaLabel`, else "Add story".
   */
  ariaLabel?: string;
  className?: string;
}

function AddStoryThumbnailInner({
  userAvatar,
  onClick,
  label,
  ariaLabel,
  className,
}: AddStoryThumbnailProps) {
  const railLabels = useStoryRailLabels() ?? DEFAULT_STORY_RAIL_LABELS;
  const [avatarFailed, setAvatarFailed] = useState<string | null>(null);
  const showAvatar = !!userAvatar && avatarFailed !== userAvatar;

  return (
    <button
      type="button"
      onClick={onClick}
      aria-label={ariaLabel ?? railLabels.addStoryAriaLabel}
      className={cn(
        "group flex shrink-0 flex-col items-center gap-2 rounded-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
    >
      <div className="relative">
        <div className="h-28 w-20 overflow-hidden rounded-2xl border-2 border-dashed border-muted-foreground/30 bg-muted/50 motion-safe:transition-colors group-hover:border-primary/50">
          {showAvatar ? (
            <img
              src={userAvatar}
              alt=""
              width={80}
              height={112}
              decoding="async"
              onError={() => setAvatarFailed(userAvatar ?? null)}
              className="h-full w-full object-cover opacity-50"
            />
          ) : (
            <div
              className="h-full w-full bg-muted"
              aria-hidden="true"
            />
          )}
        </div>
        <span
          aria-hidden="true"
          className="absolute end-1 bottom-1 flex h-6 w-6 items-center justify-center rounded-full border-2 border-card bg-primary text-primary-foreground shadow-md"
        >
          <Plus className="h-3.5 w-3.5" />
        </span>
      </div>
      <span className="w-20 truncate text-center text-xs font-medium text-muted-foreground">
        {label ?? railLabels.addStoryLabel}
      </span>
    </button>
  );
}

export const AddStoryThumbnail = memo(AddStoryThumbnailInner);
AddStoryThumbnail.displayName = "AddStoryThumbnail";
```

### `src/registry/components/data/story-rail/hooks/use-story-rail-state.ts`

```ts
"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import type {
  StoryRailDelta,
  StoryRailItem,
  StoryRailLocalAction,
  Subscribe,
} from "../types";

// useLayoutEffect warns during SSR; the effect is only needed in the browser.
const useIsoLayoutEffect =
  typeof window === "undefined" ? useEffect : useLayoutEffect;

const EMPTY: StoryRailItem[] = [];

// ─── Pure helpers ────────────────────────────────────────────────────────────

function insertAtPosition(
  state: StoryRailItem[],
  item: StoryRailItem,
  position: "start" | "end" | undefined,
): StoryRailItem[] {
  if (position === "end") return [...state, item];
  return [item, ...state]; // default "start"
}

function removeById(state: StoryRailItem[], id: string): StoryRailItem[] {
  const next = state.filter((i) => i.id !== id);
  return next.length === state.length ? state : next;
}

function patchById(
  state: StoryRailItem[],
  id: string,
  patch: Partial<StoryRailItem>,
): StoryRailItem[] {
  let mutated = false;
  const next = state.map((i) => {
    if (i.id !== id) return i;
    mutated = true;
    return { ...i, ...patch };
  });
  return mutated ? next : state;
}

// ─── Reducer ─────────────────────────────────────────────────────────────────

/** Pure reducer — exported for hosts driving their own state. */
export function storyRailReducer(
  state: StoryRailItem[],
  action: StoryRailLocalAction,
): StoryRailItem[] {
  switch (action.kind) {
    case "add":
      return insertAtPosition(state, action.item, action.position);
    case "remove":
      return removeById(state, action.itemId);
    case "viewed":
      return patchById(state, action.itemId, { hasUnread: false });
    case "update":
      return patchById(state, action.itemId, action.partial);
    case "subscribe-delta": {
      const d = action.delta;
      switch (d.kind) {
        case "added":
          return insertAtPosition(state, d.item, d.position);
        case "removed":
          return removeById(state, d.itemId);
        case "viewed":
          return patchById(state, d.itemId, { hasUnread: false });
        case "updated":
          return patchById(state, d.itemId, d.partial);
      }
      return state;
    }
    case "reset":
      return action.next;
  }
}

// ─── Hook ────────────────────────────────────────────────────────────────────

export interface UseStoryRailStateOptions {
  /** Uncontrolled seed — captured ON MOUNT only. Ignored while controlled. */
  initialItems?: StoryRailItem[];
  /**
   * Controlled value — controlled only when `onItemsChange` is ALSO passed;
   * then it wins on every render and mutations are reported instead of applied.
   */
  items?: StoryRailItem[];
  /**
   * Receives the next list for every mutation. With `items` it makes the rail
   * controlled; without `items` the rail stays uncontrolled and just notifies.
   */
  onItemsChange?: (next: StoryRailItem[]) => void;
  subscribe?: Subscribe<StoryRailDelta>;
  onSubscribeDelta?: (delta: StoryRailDelta) => void;
}

export interface UseStoryRailStateResult {
  items: StoryRailItem[];
  /**
   * Stable identity. Computes the next list from the latest one, applies it
   * when uncontrolled, and reports it through `onItemsChange` either way.
   */
  dispatch: React.Dispatch<StoryRailLocalAction>;
  /** True when both `items` and `onItemsChange` are passed. */
  isControlled: boolean;
}

export function useStoryRailState(
  opts: UseStoryRailStateOptions,
): UseStoryRailStateResult {
  const [internalItems, setInternalItems] = useState<StoryRailItem[]>(
    () => opts.initialItems ?? EMPTY,
  );

  const isControlled =
    opts.items !== undefined && opts.onItemsChange !== undefined;
  const items = isControlled ? (opts.items ?? EMPTY) : internalItems;

  // Mutations compute from the latest list. Between a mutation and the next
  // render that carries it, a second mutation (a burst of realtime deltas)
  // must build on the first — so the computed list is kept here and replaced
  // only when the list the rail renders CHANGES IDENTITY (a new prop / new
  // internal state). Unrelated rail commits (scroll state) never reset it, so
  // a host that applies updates asynchronously loses nothing. A controlled
  // host that rejects a change re-renders with a fresh array to discard it.
  const latestRef = useRef(items);
  useIsoLayoutEffect(() => {
    latestRef.current = items;
  }, [items]);

  const isControlledRef = useRef(isControlled);
  const onItemsChangeRef = useRef(opts.onItemsChange);
  const onSubscribeDeltaRef = useRef(opts.onSubscribeDelta);
  useIsoLayoutEffect(() => {
    isControlledRef.current = isControlled;
    onItemsChangeRef.current = opts.onItemsChange;
    onSubscribeDeltaRef.current = opts.onSubscribeDelta;
  });

  const dispatch = useCallback((action: StoryRailLocalAction) => {
    const prev = latestRef.current;
    const next = storyRailReducer(prev, action);
    if (next === prev) return;
    latestRef.current = next;
    if (!isControlledRef.current) setInternalItems(next);
    onItemsChangeRef.current?.(next);
  }, []);

  const subscribe = opts.subscribe;
  useEffect(() => {
    if (!subscribe) return;
    const unsub = subscribe((delta) => {
      onSubscribeDeltaRef.current?.(delta);
      dispatch({ kind: "subscribe-delta", delta });
    });
    return unsub;
  }, [subscribe, dispatch]);

  return { items, dispatch, isControlled };
}
```

### `src/registry/components/data/story-rail/parts/story-rail-context.ts`

```ts
"use client";

import { createContext, useContext } from "react";
import type { ResolvedStoryRailLabels } from "../types";

/**
 * Carries the rail's resolved labels to host-rendered parts placed in the
 * `leading` slot (e.g. `AddStoryThumbnail`), which the rail never renders
 * itself and so cannot pass props to. `null` outside a rail.
 */
export const StoryRailLabelsContext =
  createContext<ResolvedStoryRailLabels | null>(null);

export function useStoryRailLabels(): ResolvedStoryRailLabels | null {
  return useContext(StoryRailLabelsContext);
}
```

### `src/registry/components/data/story-rail/hooks/use-rail-environment.ts`

```ts
"use client";

import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";

const useIsoLayoutEffect =
  typeof window === "undefined" ? useEffect : useLayoutEffect;

function subscribeMedia(query: string) {
  return (onChange: () => void) => {
    if (typeof window === "undefined" || !window.matchMedia) return () => {};
    const mq = window.matchMedia(query);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  };
}

function readMedia(query: string): boolean {
  if (typeof window === "undefined" || !window.matchMedia) return false;
  return window.matchMedia(query).matches;
}

const FINE_POINTER = "(pointer: fine)";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
const subscribeFinePointer = subscribeMedia(FINE_POINTER);
const subscribeReducedMotion = subscribeMedia(REDUCED_MOTION);
const serverFalse = () => false;

/**
 * True under a fine pointer (mouse / trackpad). The server snapshot is `false`,
 * so the server render and the hydrating client render agree; the real value
 * lands right after hydration.
 */
export function useFinePointer(): boolean {
  return useSyncExternalStore(
    subscribeFinePointer,
    () => readMedia(FINE_POINTER),
    serverFalse,
  );
}

/** `prefers-reduced-motion: reduce`, SSR-safe (server snapshot `false`). */
export function useReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribeReducedMotion,
    () => readMedia(REDUCED_MOTION),
    serverFalse,
  );
}

/**
 * Resolves the text direction: the explicit `dir` prop wins; otherwise the
 * element's computed `direction`, read after mount (so the first client render
 * matches the server's `"ltr"`). Computed style covers `dir="rtl"` ancestors,
 * `dir="auto"` and CSS `direction: rtl` alike. A later change is not observed —
 * pass `dir` when your app switches direction at runtime.
 */
export function useResolvedDirection(
  ref: React.RefObject<HTMLElement | null>,
  dirProp: "ltr" | "rtl" | undefined,
): "ltr" | "rtl" {
  const [ancestorDir, setAncestorDir] = useState<"ltr" | "rtl">("ltr");
  useIsoLayoutEffect(() => {
    if (dirProp) return;
    const el = ref.current;
    if (!el) return;
    // Fallback for environments without a UA stylesheet for `dir` (jsdom):
    // the nearest explicit, non-`auto` `dir` — in a browser it agrees with
    // the computed value whenever it says "rtl".
    const explicit = el
      .closest('[dir]:not([dir="auto"])')
      ?.getAttribute("dir")
      ?.toLowerCase();
    const rtl = getComputedStyle(el).direction === "rtl" || explicit === "rtl";
    setAncestorDir(rtl ? "rtl" : "ltr");
  }, [dirProp, ref]);
  return dirProp ?? ancestorDir;
}
```
