Drift Marquee
An endless row that drifts on its own; drag it to scrub, fling it, and the items lean into the speed.
import { Children, useEffect, useState, type ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useFrameCallback,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
export type DriftMarqueeProps = {
/** The items. Each child is laid out `itemWidth` wide. */
children: ReactNode;
itemWidth: number;
/** Height of the row, including 8 pt above and below the items for their lean. Defaults to 180. */
height?: number;
/** Space between items. Defaults to 12. */
gap?: number;
/** Drift speed in pt per second. 0 holds still until dragged. Defaults to 32. */
speed?: number;
/** Which way the row drifts. Defaults to `left`. */
direction?: 'left' | 'right';
/** Let the row be dragged and flung. Defaults to true. */
draggable?: boolean;
/** How far items lean into fast movement, 0–1. Defaults to 0.6. */
lean?: number;
/** Pause drifting, e.g. while off-screen. Defaults to true. */
active?: boolean;
style?: StyleProp<ViewStyle>;
};
/** Fling speeds are clamped to this many pt per second. */
const MAX_FLING = 4200;
/**
* An endless row that drifts on its own. Grab it to scrub, fling it to send
* it spinning; it eases back to its drift, and the items lean into the
* speed as they pass.
*/
export function DriftMarquee({
children,
itemWidth,
height = 180,
gap = 12,
speed = 32,
direction = 'left',
draggable = true,
lean = 0.6,
active = true,
style,
}: DriftMarqueeProps) {
const reduced = useReduceMotion();
const items = Children.toArray(children);
const n = items.length;
const [width, setWidth] = useState(0);
const step = itemWidth + gap;
const total = Math.max(1, n * step);
// Enough copies of the row to cover the view plus one item on each side.
const copies = n === 0 ? 0 : Math.max(1, Math.ceil((width + step * 2) / total));
const span = total * copies;
const base = reduced ? 0 : (direction === 'left' ? 1 : -1) * Math.max(0, speed);
const offset = useSharedValue(0);
const velocity = useSharedValue(base);
const dragging = useSharedValue(false);
const start = useSharedValue(0);
const frame = useFrameCallback((info) => {
if (dragging.get()) return;
const dt = Math.min(0.05, (info.timeSincePreviousFrame ?? 16) / 1000);
// A fling eases back to the resting drift.
const v = base + (velocity.get() - base) * Math.exp(-3.2 * dt);
velocity.set(v);
offset.set(offset.get() + v * dt);
}, false);
useEffect(() => {
frame.setActive(active && n > 0);
// `frame` is stable for the component's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, n]);
const pan = Gesture.Pan()
.withTestId('drift-marquee')
.enabled(draggable && n > 0)
.activeOffsetX([-6, 6])
.failOffsetY([-12, 12])
.onBegin(() => {
start.set(offset.get());
})
.onStart(() => {
dragging.set(true);
})
.onUpdate((e) => {
offset.set(start.get() - e.translationX);
velocity.set(Math.max(-MAX_FLING, Math.min(MAX_FLING, -e.velocityX)));
})
.onFinalize(() => {
dragging.set(false);
});
const tilt = reduced ? 0 : Math.max(0, Math.min(1, lean));
return (
<GestureDetector gesture={pan}>
<View
collapsable={false}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[styles.row, { height }, style]}
>
{width > 0 &&
Array.from({ length: copies }, (_, c) =>
items.map((item, i) => (
<Slot
key={`${c}-${i}`}
slot={c * n + i}
step={step}
span={span}
width={itemWidth}
offset={offset}
velocity={velocity}
base={base}
tilt={tilt}
hidden={c > 0}
>
{item}
</Slot>
)),
)}
</View>
</GestureDetector>
);
}
function Slot({
children,
slot,
step,
span,
width,
offset,
velocity,
base,
tilt,
hidden,
}: {
children: ReactNode;
slot: number;
step: number;
span: number;
width: number;
offset: SharedValue<number>;
velocity: SharedValue<number>;
base: number;
tilt: number;
hidden: boolean;
}) {
const style = useAnimatedStyle(() => {
// Wrap around: positions run from one item before the left edge onward.
const raw = (slot * step - offset.get()) % span;
const x = (raw < 0 ? raw + span : raw) - step;
const rush = Math.max(-1, Math.min(1, (velocity.get() - base) / 2600));
return {
transform: [
{ translateX: x },
{ rotateZ: `${-rush * 5 * tilt}deg` },
{ scale: 1 - Math.abs(rush) * 0.05 * tilt },
],
};
});
return (
<Animated.View
style={[styles.slot, { width }, style]}
// Copies exist only to fill the loop; screen readers get each item once.
importantForAccessibility={hidden ? 'no-hide-descendants' : 'auto'}
accessibilityElementsHidden={hidden}
>
{children}
</Animated.View>
);
}
const styles = StyleSheet.create({
row: { overflow: 'hidden' },
// A little room above and below so leaning items are not clipped.
slot: { position: 'absolute', top: 8, bottom: 8, left: 0 },
});Installation
npx kinetik-ui add drift-marqueepnpm dlx kinetik-ui add drift-marqueebunx kinetik-ui add drift-marqueeyarn dlx kinetik-ui add drift-marqueeThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/drift-marquee.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/drift-marquee.tsx
Usage
import { DriftMarquee } from '@/components/kinetik/drift-marquee';
<DriftMarquee itemWidth={160} height={200}>
{covers.map((c) => (
<Image key={c.id} source={{ uri: c.url }} style={{ flex: 1, borderRadius: 16 }} />
))}
</DriftMarquee>;Every child becomes an item itemWidth wide that fills the row's height. The row repeats itself to fill the width, so even a few items loop without a gap.
When to use
- Showcases and landing screens: covers, logos, artworks, testimonials.
- A lively background row that people can also play with.
When not to use
- Content people need to read or tap precisely. It keeps moving.
- Inside horizontal scroll views; both want the sideways drag.
Notes
speedis in points per second; set it to 0 for a row that only moves when dragged.- A fling eases back to the drift;
leansets how far items tilt at speed. - Pass
active={false}while the row is off-screen to stop its frame callback.
Props
DriftMarqueeProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | The items. Each child is laid out `itemWidth` wide. |
itemWidthrequired | number | — | |
height | number | 180 | Height of the row, including 8 pt above and below the items for their lean. Defaults to 180. |
gap | number | 12 | Space between items. Defaults to 12. |
speed | number | 32 | Drift speed in pt per second. 0 holds still until dragged. Defaults to 32. |
direction | 'left' | 'right' | 'left' | Which way the row drifts. Defaults to `left`. |
draggable | boolean | true | Let the row be dragged and flung. Defaults to true. |
lean | number | 0.6 | How far items lean into fast movement, 0–1. Defaults to 0.6. |
active | boolean | true | Pause drifting, e.g. while off-screen. Defaults to true. |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- A frame callback advances one offset on the UI thread; each item wraps around with a single transform. Pass active={false} off-screen to stop it.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- No drift and no lean. The row still scrolls by dragging.
- Screen readers
- Each item is read once; the copies that fill the loop are hidden.
- Touch target
- The whole row is the drag area.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/drift-marquee.tsx
import { Children, useEffect, useState, type ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useFrameCallback,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
export type DriftMarqueeProps = {
/** The items. Each child is laid out `itemWidth` wide. */
children: ReactNode;
itemWidth: number;
/** Height of the row, including 8 pt above and below the items for their lean. Defaults to 180. */
height?: number;
/** Space between items. Defaults to 12. */
gap?: number;
/** Drift speed in pt per second. 0 holds still until dragged. Defaults to 32. */
speed?: number;
/** Which way the row drifts. Defaults to `left`. */
direction?: 'left' | 'right';
/** Let the row be dragged and flung. Defaults to true. */
draggable?: boolean;
/** How far items lean into fast movement, 0–1. Defaults to 0.6. */
lean?: number;
/** Pause drifting, e.g. while off-screen. Defaults to true. */
active?: boolean;
style?: StyleProp<ViewStyle>;
};
/** Fling speeds are clamped to this many pt per second. */
const MAX_FLING = 4200;
/**
* An endless row that drifts on its own. Grab it to scrub, fling it to send
* it spinning; it eases back to its drift, and the items lean into the
* speed as they pass.
*/
export function DriftMarquee({
children,
itemWidth,
height = 180,
gap = 12,
speed = 32,
direction = 'left',
draggable = true,
lean = 0.6,
active = true,
style,
}: DriftMarqueeProps) {
const reduced = useReduceMotion();
const items = Children.toArray(children);
const n = items.length;
const [width, setWidth] = useState(0);
const step = itemWidth + gap;
const total = Math.max(1, n * step);
// Enough copies of the row to cover the view plus one item on each side.
const copies = n === 0 ? 0 : Math.max(1, Math.ceil((width + step * 2) / total));
const span = total * copies;
const base = reduced ? 0 : (direction === 'left' ? 1 : -1) * Math.max(0, speed);
const offset = useSharedValue(0);
const velocity = useSharedValue(base);
const dragging = useSharedValue(false);
const start = useSharedValue(0);
const frame = useFrameCallback((info) => {
if (dragging.get()) return;
const dt = Math.min(0.05, (info.timeSincePreviousFrame ?? 16) / 1000);
// A fling eases back to the resting drift.
const v = base + (velocity.get() - base) * Math.exp(-3.2 * dt);
velocity.set(v);
offset.set(offset.get() + v * dt);
}, false);
useEffect(() => {
frame.setActive(active && n > 0);
// `frame` is stable for the component's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, n]);
const pan = Gesture.Pan()
.withTestId('drift-marquee')
.enabled(draggable && n > 0)
.activeOffsetX([-6, 6])
.failOffsetY([-12, 12])
.onBegin(() => {
start.set(offset.get());
})
.onStart(() => {
dragging.set(true);
})
.onUpdate((e) => {
offset.set(start.get() - e.translationX);
velocity.set(Math.max(-MAX_FLING, Math.min(MAX_FLING, -e.velocityX)));
})
.onFinalize(() => {
dragging.set(false);
});
const tilt = reduced ? 0 : Math.max(0, Math.min(1, lean));
return (
<GestureDetector gesture={pan}>
<View
collapsable={false}
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
style={[styles.row, { height }, style]}
>
{width > 0 &&
Array.from({ length: copies }, (_, c) =>
items.map((item, i) => (
<Slot
key={`${c}-${i}`}
slot={c * n + i}
step={step}
span={span}
width={itemWidth}
offset={offset}
velocity={velocity}
base={base}
tilt={tilt}
hidden={c > 0}
>
{item}
</Slot>
)),
)}
</View>
</GestureDetector>
);
}
function Slot({
children,
slot,
step,
span,
width,
offset,
velocity,
base,
tilt,
hidden,
}: {
children: ReactNode;
slot: number;
step: number;
span: number;
width: number;
offset: SharedValue<number>;
velocity: SharedValue<number>;
base: number;
tilt: number;
hidden: boolean;
}) {
const style = useAnimatedStyle(() => {
// Wrap around: positions run from one item before the left edge onward.
const raw = (slot * step - offset.get()) % span;
const x = (raw < 0 ? raw + span : raw) - step;
const rush = Math.max(-1, Math.min(1, (velocity.get() - base) / 2600));
return {
transform: [
{ translateX: x },
{ rotateZ: `${-rush * 5 * tilt}deg` },
{ scale: 1 - Math.abs(rush) * 0.05 * tilt },
],
};
});
return (
<Animated.View
style={[styles.slot, { width }, style]}
// Copies exist only to fill the loop; screen readers get each item once.
importantForAccessibility={hidden ? 'no-hide-descendants' : 'auto'}
accessibilityElementsHidden={hidden}
>
{children}
</Animated.View>
);
}
const styles = StyleSheet.create({
row: { overflow: 'hidden' },
// A little room above and below so leaning items are not clipped.
slot: { position: 'absolute', top: 8, bottom: 8, left: 0 },
});