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.
@tanstack/react-virtual with auto measurement and overscan tuning.prev/next/scrollTo.role="feed" viewport, per-item role="article", aria-label, aria-busy, snap alignment.prefers-reduced-motion uses instant scrolling).Using your preferred package manager:
bun add swipefeed
# or
npm install swipefeed
Peer dependencies: react@^19, react-dom@^19.
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>
);
}
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>
);
}
h-screen); items are absolutely positioned with transforms supplied by getItemProps.overflow: auto on the viewport (default from getViewportProps).html, body, #root stretch to 100%.<SwipeDeck> propsExtends SwipeDeckOptions<T> plus:
as: custom element/component for the viewport (default "div").children(context): render prop; receives { item, index, isActive, props }.className, style: forwarded to the viewport.ref: imperative handle (SwipeDeckHandle) with prev, next, scrollTo, getState.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 (default 0).index: controlled index. When set, you must manage updates via onIndexChange.onIndexChange(index, source): fires on any navigation. source is one of "user:gesture" | "user:wheel" | "user:keyboard" | "programmatic" | "snap".loop: wrap navigation at ends (default false).gesture: { threshold, flickVelocity, lockAxis, ignoreWhileAnimating } (defaults 10, 0.1, true, true).wheel: { discretePaging, threshold, debounce, cooldown } (defaults true, 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 (default true).virtual: { overscan, estimatedSize, getItemKey } (defaults 5, auto-measured viewport size, index key). Backed by @tanstack/react-virtual.endReachedThreshold: number of items from ends to trigger onEndReached (default 3).onEndReached({ distanceFromEnd, direction }): called when within threshold from start or end.ariaLabel: label for the feed (default "Swipe feed").visibility: reserved for future visibility strategies (currently unused).onItemActive, onItemInactive: reserved hooks for future activation callbacks (currently no-ops).children)item: the data item.index: item index.isActive: whether the item is currently centered/active.props: spread onto your item element (includes refs, transforms, snap styles, data attributes).SwipeDeckHandle)prev(), next()scrollTo(index, { behavior })getState(): { index, isAnimating, canPrev, canNext }useSwipeDeck return shapeindex, isAnimating, canPrev, canNext, items, orientation.prev(), next(), scrollTo(index, { behavior }).virtualItems (offset/size/key/measureElement), totalSize.getViewportProps(), getItemProps(index).useWindowSize(): throttled window dimensions { width, height }.@tanstack/react-virtual under the hood for consistent offsets, even for small lists.virtual.estimatedSize is omitted, the viewport is measured via ResizeObserver and used as the estimate, keeping slides at viewport size by default.virtual.getItemKey lets you provide stable keys (otherwise the index is used).virtual.overscan tunes how many items render around the viewport (default 5).scrollTo chooses smooth vs instant based on prefers-reduced-motion, and temporarily disables snap while animating to avoid fights with CSS snap.role="feed", aria-label, aria-busy while animating, focusable (tabIndex=0), native scroll-snap, touch-action set per axis.role="article" with aria-label that includes index/length; snap alignment applied on each item.onEndReached fires both near the start and near the end when within endReachedThreshold items.loop wraps navigation for gestures, wheel, keyboard, and programmatic calls.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:5173
Key bits: render prop usage, global keyboard navigation, gesture swipe, mute button overlay, and custom chrome.
bun run build – bundle src/ to dist/ with sourcemaps.bun run docs:api – generate markdown API docs in docs/api.bun run docs:site – generate HTML TypeDoc site to site/docs (default theme).bun run example:build:site – build the Vite example with base=/example/.bun run site:prepare – rebuild docs+example into site/ for GitHub Pages.bun run test – Vitest (jsdom) unit suite.bun run test:browser – Vitest browser runner (wheel/scroll integration).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.bun run site:prepare (produces site/docs for API HTML, site/example for the Vite demo).master and the Pages workflow builds both sections and publishes site/ to the gh-pages branch (docs at /docs/, example at /swipefeed/example/ by default).VITE_BASE=/<your-repo>/example/ when running example:build:site or adjust the workflow accordingly.visibility, onItemActive, and onItemInactive options are present for future parity with the design spec but are not wired yet.