Skip to content
ilinxa/pro-ui

Story Rail

alphav0.3.0

Horizontal story rail with unread gradient rings, drag-free skim scrolling, an add-story tile, and edge-fade gradients.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-03Author: ilinxa

Context

Seventh of 8 in the social-posts-system arc — story doublet, part 1. Embla used directly (sealed-folder rule — story-rail's start-aligned + drag-free + small-rectangle config differs structurally from media-carousel's centered + snap + large-square gallery). Migration origin: kasder kas-social-front-v0 StoriesSection.tsx + StoryThumbnail.tsx. Realtime contract identical shape to engagement-bar / comment-thread / post-card — single Subscribe<TDelta> mental model across the family. Click does NOT auto-mark-viewed (matches Instagram); host calls ref.current.markViewed(itemId) when their viewer closes. Gradient color adapts to framed mode: from-card when wrapped in card chrome (kasder convention), from-background when bare. Pairs with the upcoming story-viewer (eighth ship; FM adoption gate) for the full doublet — viewer takes the FULL Story shape with items[] inner content; rail takes a minimal preview shape. TypeScript structural typing means hosts can pass a Story to story-rail if its fields are a superset; no adapter needed.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/story-rail

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/story-rail-fixtures

Preview

Demo source

demo.tsxtsx

Usage

When to use

Reach for StoryRail when you need a horizontal stories rail at the top of a feed — kasder-style portrait thumbnails with a gradient ring (unread) or muted ring (read), drag-free skim-scroll, and edge-fade gradients. Decoupled from the viewer: onItemClick({ item, index }) hands off to whatever your host renders (your own viewer, the future story-viewer, a navigation push, etc.).

Footgun: the `items` prop is mount-only

Component captures items as initial state on mount; subsequent prop reference changes are ignored. Realtime subscribemutates the internal store; for external pushes use the imperative handle's reset(next) or surgical dispatch(action).

Minimal usage

import { StoryRail } from "@/components/story-rail";

<StoryRail
  items={stories}
  onItemClick={({ item, index }) => openViewer(index)}
/>

With AddStoryThumbnail (kasder UX)

import {
  StoryRail,
  AddStoryThumbnail,
} from "@/components/story-rail";

<StoryRail
  items={stories}
  leading={
    <AddStoryThumbnail
      userAvatar={viewer.avatar}
      onClick={() => openStoryComposer()}
    />
  }
  onItemClick={({ item, index }) => openViewer(index)}
/>

Realtime via subscribe

import type {
  Subscribe,
  StoryRailDelta,
} from "@/components/story-rail";

const subscribe = useCallback<Subscribe<StoryRailDelta>>(
  (handler) => channel.on("stories", handler),
  [channel],
);

<StoryRail
  items={stories}
  subscribe={subscribe}
  onSubscribeDelta={(d) => analytics.track("story-rail-delta", d)}
/>

Hosts must memoize subscribe via useCallback — identity changes trigger a clean teardown + re-call (same convention as engagement-bar / comment-thread / post-card).

Mark viewed (from your viewer's onClose)

const railRef = useRef<StoryRailHandle>(null);

<StoryRail
  ref={railRef}
  items={stories}
  onItemClick={({ item, index }) => {
    setActiveStoryIndex(index);
    setViewerOpen(true);
  }}
/>

<MyStoryViewer
  open={viewerOpen}
  story={stories[activeStoryIndex]}
  onClose={() => {
    setViewerOpen(false);
    railRef.current?.markViewed(stories[activeStoryIndex].id);
  }}
/>

Click does NOT auto-mark-viewed (matches Instagram — the ring stays until the user actually completes the story). Host calls markViewed(itemId) when their viewer closes.

Custom thumbnail render

<StoryRail
  items={stories}
  renderThumbnail={(item, isUnread, { onClick, baseId }) => (
    <BrandedStoryThumbnail
      item={item}
      isUnread={isUnread}
      onClick={onClick}
      ariaLabelledBy={baseId}
    />
  )}
/>

Bare (no card frame)

<StoryRail items={stories} framed={false} className="px-4" />

With framed: false, the card chrome is removed and the edge gradients use from-background + left-0/right-0 so they blend with whatever container you embed in.

Notes

  • Embla used inline with align: "start", containScroll: "trimSnaps", dragFree: true (kasder-exact). No indicator dots — story rails are skim-scroll, not snap carousels.
  • Thumbnail dimensions locked to w-20 h-28 (80×112). For different sizes, use the renderThumbnail slot.
  • Unread ring: bg-linear-to-br from-accent via-warning to-destructive. Read ring: bg-muted.
  • Edge gradients are aria-hidden + pointer-events-none; don't intercept drag.
  • StoryRailItem.previewImage is required (no placeholder fallback in v0.1). Hosts ensure preview URLs exist before passing.
  • linkComponent + getHref co-exist with onItemClick — both fire on click. Use this for analytics on a navigation-mode rail.
  • For external state coordination, the storyRailReducer and useStoryRailState are publicly exported.

Features

  • Kasder-exact thumbnail aesthetic — w-20 h-28 portrait, gradient ring (unread) vs muted ring (read), avatar+username row below
  • AddStoryThumbnail standalone sub-export — dashed-border placeholder + 50%-opacity user avatar + Plus badge
  • leading?: ReactNode slot — render any custom prefix (AddStoryThumbnail / Live indicator / Pinned callout / etc.)
  • Realtime via Subscribe<StoryRailDelta> contract — added / removed / viewed / updated; same shape as engagement-bar / comment-thread / post-card
  • onSubscribeDelta callback fires for every delta
  • Embla used inline (no wrapper hook, no cross-import) — align: 'start', containScroll: 'trimSnaps', dragFree: true (kasder-exact)
  • No indicator dots — story rails are skim-scroll, not snap carousels
  • Mode-aware edge gradients — from-card + left-4/right-4 when framed; from-background + left-0/right-0 when bare
  • Edge gradients render only when items present (not over empty state)
  • Click does NOT auto-mark-viewed — host owns viewing semantics via ref.current.markViewed(itemId)
  • renderThumbnail slot for full per-thumbnail takeover (themed rings, video previews, custom shapes)
  • Polymorphic linkComponent + getHref for navigation-mode (rare; thumbnails usually open modal viewer)
  • Imperative handle: scrollTo / getCurrentItems / reset / dispatch / markViewed
  • storyRailReducer + useStoryRailState publicly exported (external state coordination)
  • Always-uncontrolled — `items` prop is mount-only; reset(next) for external state push
  • i18n via 5-key labels object including thumbnailAriaLabel(item) function for unread/viewed string
  • a11y — section role=region, button per thumbnail with descriptive aria-label, edge gradients aria-hidden
  • motion-safe:group-hover:scale-105 on thumbnails — reduced-motion users see static
  • Tailwind v4-clean (no legacy class names)
  • No new shadcn primitives — avatar already installed
  • No framer-motion — CSS transitions only
  • v0.2.1 — usage.tsx docs patch: 3 stale positional `onItemClick(item, index)` snippets + 1 prose mention updated to object-shape `({ item, index })` (v0.2 contract). Zero code change.

Tags

story-railsocialstoriesrailcarouselemblarealtimeinstagram

Dependencies

shadcn primitives: avatar
npm peer deps: embla-carousel-react@^8.6.0, lucide-react@^1.11.0