swipefeed
v1.2.0
Published
<p align="center"> <img src="docs/banner.png" alt="swipefeed: headless React primitives for swipe feeds" width="320"> </p>
Readme
swipefeed
Headless React primitives for building TikTok/Reels-style swipe feeds. Ships a render-prop component (SwipeDeck) and a hook (useSwipeDeck) that wire up native scroll-snap, TanStack virtualization, gesture/wheel/keyboard inputs, accessibility, and programmatic controls.

Highlights
- Headless: bring your own markup/styles; spread provided props for behavior.
- Works vertically or horizontally with LTR/RTL awareness.
- Virtualization via
@tanstack/react-virtualwith auto measurement and overscan tuning. - Input support: pointer drag/flick, discrete wheel paging with heavy dampening, keyboard (global or scoped).
- Controlled or uncontrolled index, loopable navigation, programmatic
prev/next/scrollTo. - Animation-frame-throttled motion snapshots for synchronized media and visual effects.
- Position- or intersection-based activation with exact active/inactive lifecycle callbacks.
- Accessibility-first: focusable
role="feed"viewport, per-itemrole="article",aria-label,aria-busy, snap alignment. - Reduced-motion aware (
prefers-reduced-motionuses instant scrolling). - End-reached callbacks for infinite loading triggers.
Installation
Using your preferred package manager:
bun add swipefeed
# or
npm install swipefeedPeer dependencies: react@^19, react-dom@^19.
Quick start (component)
import { SwipeDeck } from "swipefeed";
function Feed({ items }) {
return (
<SwipeDeck items={items} className="w-full h-screen overflow-hidden">
{({ item, isActive, props }) => (
<section
{...props}
style={{ height: "100vh" }} // make each slide fill the viewport
>
<VideoPlayer video={item} playing={isActive} />
</section>
)}
</SwipeDeck>
);
}Quick start (hook)
import { useSwipeDeck } from "swipefeed";
function CustomLayout({ items }) {
const deck = useSwipeDeck({ items, orientation: "horizontal", direction: "rtl" });
const viewportProps = deck.getViewportProps();
return (
<div {...viewportProps} style={{ ...viewportProps.style, height: "100vh" }}>
<div style={{ width: deck.totalSize, position: "relative" }}>
{deck.virtualItems.map((virtual) => {
const props = deck.getItemProps(virtual.index);
return (
<article key={virtual.key} {...props}>
{items[virtual.index].title}
</article>
);
})}
</div>
</div>
);
}Styling essentials
- Give the viewport a fixed height/width (e.g.,
h-screen); items are absolutely positioned with transforms supplied bygetItemProps. - Native scroll-snap is applied for you; keep
overflow: autoon the viewport (default fromgetViewportProps). - For full-screen feeds, ensure
html, body, #rootstretch to 100%.
API reference
<SwipeDeck> props
Extends SwipeDeckOptions<T> plus:
as: custom element/component for the viewport (default"div").children(context): render prop; receives{ item, index, isActive, shouldPreload, props }.className,style: forwarded to the viewport.ref: imperative handle (SwipeDeckHandle) withprev,next,scrollTo,getState,getMotion.
SwipeDeckOptions<T>
items(required): readonly array of items.orientation:"vertical"(default) |"horizontal".direction:"ltr"(default) |"rtl"(affects horizontal gestures/wheel/keyboard).defaultIndex: initial index for uncontrolled mode (default0).index: controlled index. When set, you must manage updates viaonIndexChange.onIndexChange(index, source): fires on any navigation.sourceis one of"user:gesture" | "user:wheel" | "user:keyboard" | "programmatic" | "visibility" | "snap".onMotionChange(motion): animation-frame-throttled continuous motion callback. It does not put per-pixel motion into React render state.loop: wrap navigation at ends (defaultfalse).gesture:{ threshold, flickVelocity, lockAxis, ignoreWhileAnimating }(defaults10,0.1,true,true).wheel:{ discretePaging, threshold, debounce, cooldown }(defaultstrue,100,120ms,800ms) with aggressive dampening to prevent multi-item jumps.keyboard:{ enabled, global, bindings }(default enabled, scoped to viewport). Bindings default to arrows + Home/End + Page keys; RTL flips horizontal intent.keyboardNavigation: boolean shorthand to disable keyboard entirely (defaulttrue).virtual:{ overscan, estimatedSize, getItemKey }(defaults5, auto-measured viewport size,indexkey). Backed by@tanstack/react-virtual.endReachedThreshold: number of items from ends to triggeronEndReached(default3).onEndReached({ distanceFromEnd, direction }): called when within threshold from start or end.ariaLabel: label for the feed (default"Swipe feed").visibility:{ strategy, intersectionRatio, debounce }.strategydefaults to"position";"intersection"activates an item after it crossesintersectionRatio(default0.6) and falls back to position whenIntersectionObserveris unavailable.onItemActive(item, index),onItemInactive(item, index): exact lifecycle transitions for the active item. The initial item activates on mount and the final item becomes inactive on unmount.
Render context (children)
item: the data item.index: item index.isActive: whether the item is currently centered/active.shouldPreload: whether the item is in the configured forward/backward preload window.props: spread onto your item content (data and accessibility attributes; SwipeDeck's outer article owns its internal ref and positioning styles).
Imperative handle (SwipeDeckHandle)
prev(),next()scrollTo(index, { behavior })getState():{ index, isAnimating, canPrev, canNext }getMotion(): latestSwipeDeckMotionsnapshot without subscribing React render state.
useSwipeDeck return shape
- State:
index,isAnimating,canPrev,canNext,items,orientation. - Actions:
prev(),next(),scrollTo(index, { behavior }). - Motion:
getMotion()for the latest snapshot; useonMotionChangeto subscribe. - Layout:
virtualItems(offset/size/key/measureElement),totalSize. - Props helpers:
getViewportProps(),getItemProps(index).
Continuous motion
onMotionChange emits { scrollOffset, viewportSize, position, index, offset, offsetRatio, direction, isSettled }. position is fractional, while index is the nearest item. offset is the nearest item's pixel displacement from its settled position, which makes a shared media layer follow the outgoing and incoming cards without reading the viewport DOM directly.
The callback is throttled with requestAnimationFrame. Keep high-frequency visual work imperative (for example, update a transform); only copy coarse changes such as motion.index into React state.
Helper hook
useWindowSize(): throttled window dimensions{ width, height }.
Virtualization behavior
- Always uses
@tanstack/react-virtualunder the hood for consistent offsets, even for small lists. - If
virtual.estimatedSizeis omitted, the viewport is measured viaResizeObserverand used as the estimate, keeping slides at viewport size by default. virtual.getItemKeylets you provide stable keys (otherwise the index is used).virtual.overscantunes how many items render around the viewport (default5).
Input handling
- Gestures: pointer drag + flick with threshold/velocity detection, axis locking, and optional ignore-during-animation.
- Wheel: discrete paging with direction locking, debounce, cooldown, and delta caps to prevent multi-item jumps on trackpads.
- Keyboard: arrow/Home/End/PageUp/PageDown; can be global or scoped to the viewport; RTL-aware for horizontal decks.
- Programmatic:
scrollTochoosessmoothvsinstantbased onprefers-reduced-motion, and temporarily disables snap while animating to avoid fights with CSS snap.
Accessibility
- Viewport:
role="feed",aria-label,aria-busywhile animating, focusable (tabIndex=0), native scroll-snap,touch-actionset per axis. - Items:
role="article"witharia-labelthat includes index/length; snap alignment applied on each item. - RTL-aware keyboard/wheel for horizontal feeds.
End reached & looping
onEndReachedfires both near the start and near the end when withinendReachedThresholditems.loopwraps navigation for gestures, wheel, keyboard, and programmatic calls.
Example app
A TikTok-style vertical feed using YouTube iframes lives in example/.
bun install # installs root + workspace deps
bun run build # optional: build the library once
cd example
bun run dev # starts Vite on http://localhost:5173Key bits: render prop usage, shared-player synchronization through onMotionChange, global keyboard navigation, gesture swipe, mute button overlay, and custom chrome.
Scripts (root)
bun run build– bundlesrc/todist/with sourcemaps.bun run docs:api– generate markdown API docs indocs/api.bun run docs:site– generate HTML TypeDoc site tosite/docs(default theme).bun run example:build:site– build the Vite example withbase=/example/.bun run site:prepare– rebuild docs+example intosite/for GitHub Pages.bun run test– Vitest (jsdom) unit suite.bun run test:browser– Vitest browser runner (wheel/scroll integration).bun run test:example:live -- http://127.0.0.1:<port>/– against an already-running example, verify every catalog video is embeddable, portrait, playing after unmute, and loaded through one stable iframe. Choose an unused local port; this network-dependent release check is intentionally separate from deterministic CI.bun run test:coverage,bun run test:browser:coverage,bun run coverage:merge– coverage reports/merge.bun run lint/bun run format– Biome lint/format.bun run typecheck– TypeScript.bun run storybook/bun run storybook:build– Storybook dev/build.
GitHub Pages (docs + example)
- Build locally:
bun run site:prepare(producessite/docsfor API HTML,site/examplefor the Vite demo). - Deploy via CI: push to
masterand the Pages workflow builds both sections and publishessite/to thegh-pagesbranch (docs at/docs/, example at/swipefeed/example/by default). - If you fork/rename the repo or use a custom Pages root, set
VITE_BASE=/<your-repo>/example/when runningexample:build:siteor adjust the workflow accordingly.
Notes & limitations
- Ensure your viewport has a defined size; virtualization, motion snapshots, and snap rely on it.
