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 init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/story-railAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/story-rail-fixturesPreview
Demo source
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.