Story Rail
alphav0.3.0Horizontal story rail with unread gradient rings, drag-free skim scrolling, an add-story tile, and edge-fade gradients.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/story-railAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/story-rail-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Demo source
"use client"; import { useMemo, useRef, useState } from "react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { StoryViewer } from "../../media/story-viewer";import type { Story } from "../../media/story-viewer";import { StoryRail } from "./story-rail";import { AddStoryThumbnail } from "./parts/add-story-thumbnail";import { DUMMY_STORIES, DUMMY_VIEWER_AVATAR, createDummyStoryRailSubscribe,} from "./dummy-data";import type { StoryRailHandle, StoryRailItem } from "./types"; function log(tag: string, payload: unknown) { if (typeof console !== "undefined") { console.log(`[demo:story-rail:${tag}]`, payload); }} const NOW = new Date("2026-05-03T14:00:00Z");const isoMinusMin = (min: number) => new Date(NOW.getTime() - min * 60_000).toISOString(); /** * Real Unsplash + w3schools URLs (verified working) cycled across the 7 rail items * so the viewer's nav arrows + tap zones visibly advance to a different image * each time. Demo-only — NOT in dummy-data.ts (which ships via story-rail-fixtures * and stays viewer-free). */const SECONDARY_IMAGES = [ "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=900&h=1600&fit=crop", "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=900&h=1600&fit=crop", "https://images.unsplash.com/photo-1454496522488-7a8e488e8606?w=900&h=1600&fit=crop", "https://images.unsplash.com/photo-1504805572947-34fad45aed93?w=900&h=1600&fit=crop", "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?w=900&h=1600&fit=crop", "https://images.unsplash.com/photo-1444080748397-f442aa95c3e5?w=900&h=1600&fit=crop", "https://images.unsplash.com/photo-1505765050516-f72dcac9c60e?w=900&h=1600&fit=crop",]; /** * Full-Story shape for the viewer, parallel to DUMMY_STORIES (same IDs). * Rail dummies are minimal previews; viewer needs full items[] + createdAt. */const DUMMY_FULL_STORIES: Story[] = DUMMY_STORIES.map((rail, idx) => ({ id: rail.id, userId: rail.userId ?? `u${idx + 1}`, username: rail.username, avatar: rail.avatar, hasUnread: rail.hasUnread, createdAt: isoMinusMin((idx + 1) * 18), items: idx === 1 ? [ { id: `${rail.id}-i1`, type: "video", src: "https://www.w3schools.com/html/mov_bbb.mp4" }, { id: `${rail.id}-i2`, type: "image", src: rail.previewImage, duration: 5 }, ] : idx === 4 ? [ { id: `${rail.id}-i1`, type: "image", src: rail.previewImage, duration: 5 }, { id: `${rail.id}-i2`, type: "video", src: "https://www.w3schools.com/html/movie.mp4" }, ] : [ { id: `${rail.id}-i1`, type: "image", src: rail.previewImage, duration: 5 }, { id: `${rail.id}-i2`, type: "image", src: SECONDARY_IMAGES[idx], duration: 5 }, ],})); interface RailWithViewerProps { framed?: boolean; showAddLeading?: boolean; renderThumbnail?: React.ComponentProps<typeof StoryRail>["renderThumbnail"]; subscribe?: React.ComponentProps<typeof StoryRail>["subscribe"];} /** * Shared rail+viewer wiring — the canonical pattern from the guide: * rail click sets activeIdx, viewer opens, viewer's onStoryViewed feeds back * into railRef.markViewed to clear the unread ring. */function RailWithViewer({ framed = true, showAddLeading = false, renderThumbnail, subscribe,}: RailWithViewerProps) { const railRef = useRef<StoryRailHandle | null>(null); const [activeIdx, setActiveIdx] = useState(-1); return ( <div className="mx-auto flex max-w-2xl flex-col gap-3"> <StoryRail ref={railRef} items={DUMMY_STORIES} framed={framed} leading={ showAddLeading ? ( <AddStoryThumbnail userAvatar={DUMMY_VIEWER_AVATAR} onClick={() => log("add-story", null)} /> ) : undefined } renderThumbnail={renderThumbnail} subscribe={subscribe} onSubscribeDelta={subscribe ? (d) => log("delta", d) : undefined} onItemClick={({ item, index }) => { log("click", { item, index }); setActiveIdx(index); }} /> {activeIdx >= 0 ? ( <StoryViewer stories={DUMMY_FULL_STORIES} initialStoryIndex={activeIdx} isOpen onClose={() => setActiveIdx(-1)} onStoryViewed={(id) => { log("story-viewed", id); railRef.current?.markViewed(id); }} onAutoCloseAtEnd={() => log("auto-close-at-end", null)} /> ) : null} </div> );} function DefaultTab() { return <RailWithViewer />;} function WithAddTab() { return <RailWithViewer showAddLeading />;} function MixedReadUnreadTab() { return <RailWithViewer />;} function RealtimeTab() { const subscribe = useMemo(() => createDummyStoryRailSubscribe(), []); return <RailWithViewer showAddLeading subscribe={subscribe} />;} function CustomRenderTab() { return ( <RailWithViewer renderThumbnail={(item: StoryRailItem, isUnread, { onClick }) => ( <button type="button" onClick={onClick} aria-label={item.username} className="group flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl border-2 border-card bg-muted shadow-sm transition-transform hover:scale-105" > <img src={item.previewImage} alt="" className="h-full w-full object-cover" style={{ filter: isUnread ? "saturate(1.1)" : "saturate(0.4)", }} /> </button> )} /> );} function BareTab() { return <RailWithViewer framed={false} showAddLeading />;} function EmptyTab() { const [items] = useState<StoryRailItem[]>([]); return ( <div className="mx-auto max-w-2xl"> <StoryRail items={items} /> </div> );} export default function StoryRailDemo() { return ( <Tabs defaultValue="default" className="w-full"> <SwipeTabsList> <TabsTrigger value="default">Default</TabsTrigger> <TabsTrigger value="add">+ Add</TabsTrigger> <TabsTrigger value="mixed">Mixed</TabsTrigger> <TabsTrigger value="realtime">Realtime</TabsTrigger> <TabsTrigger value="custom">Custom</TabsTrigger> <TabsTrigger value="bare">Bare</TabsTrigger> <TabsTrigger value="empty">Empty</TabsTrigger> </SwipeTabsList> <TabsContent value="default" className="mt-4"> <DefaultTab /> </TabsContent> <TabsContent value="add" className="mt-4"> <WithAddTab /> </TabsContent> <TabsContent value="mixed" className="mt-4"> <MixedReadUnreadTab /> </TabsContent> <TabsContent value="realtime" className="mt-4"> <RealtimeTab /> </TabsContent> <TabsContent value="custom" className="mt-4"> <CustomRenderTab /> </TabsContent> <TabsContent value="bare" className="mt-4"> <BareTab /> </TabsContent> <TabsContent value="empty" className="mt-4"> <EmptyTab /> </TabsContent> </Tabs> );} 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 therenderThumbnailslot. - 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.previewImageis required (no placeholder fallback in v0.1). Hosts ensure preview URLs exist before passing.linkComponent+getHrefco-exist withonItemClick— both fire on click. Use this for analytics on a navigation-mode rail.- For external state coordination, the
storyRailReduceranduseStoryRailStateare 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.