Scroll Stack
A scroll view whose cards pin to the top and pile up, the ones beneath shrinking and dimming as more land.
import { Children, useState, type ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type ScrollStackProps = {
/** The cards. Each child becomes one card of `cardHeight`. */
children: ReactNode;
/** Scrolls away above the stack. */
header?: ReactNode;
/** Height of every card. Defaults to 300. */
cardHeight?: number;
/** Space between cards before they stack. Defaults to 24. */
gap?: number;
/** How much of each stacked card stays visible above the next. Defaults to 12. */
peek?: number;
/** Where the stack pins, from the top of the scroll view. Defaults to 16. */
topInset?: number;
/** How much a covered card shrinks per card on top of it, 0–0.15. Defaults to 0.06. */
depth?: number;
/** Corner radius of the cards. Defaults to the theme `xl` radius. */
radius?: number;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
};
/**
* A scroll view whose cards pin to the top and pile up: each one slides over
* the last, and the cards underneath shrink and dim a little more with every
* card that lands on them.
*/
export function ScrollStack({
children,
header,
cardHeight = 300,
gap = 24,
peek = 12,
topInset = 16,
depth = 0.06,
radius,
style,
contentContainerStyle,
}: ScrollStackProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const cards = Children.toArray(children);
const n = cards.length;
const r = radius ?? theme.radius.xl;
const k = Math.max(0, Math.min(0.15, depth));
const y = useSharedValue(0);
const [headerH, setHeaderH] = useState(0);
const [viewH, setViewH] = useState(0);
const onScroll = useAnimatedScrollHandler((e) => {
y.set(e.contentOffset.y);
});
// Room under the last card so it can scroll up to its pinned place.
const tail = Math.max(0, viewH - cardHeight - topInset - (n - 1) * peek);
const stackH = n * cardHeight + (n - 1) * gap + tail;
return (
<Animated.ScrollView
onScroll={onScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
onLayout={(e) => setViewH(e.nativeEvent.layout.height)}
style={[styles.root, style]}
contentContainerStyle={contentContainerStyle}
>
{header ? (
<View onLayout={(e) => setHeaderH(e.nativeEvent.layout.height)}>{header}</View>
) : null}
<View style={{ height: stackH }}>
{cards.map((card, i) => (
<Card
key={i}
index={i}
count={n}
y={y}
top={i * (cardHeight + gap)}
// Scroll position at which this card reaches its pinned place.
pin={headerH + i * (cardHeight + gap) - topInset - i * peek}
travel={cardHeight + gap - peek}
height={cardHeight}
depth={reduced ? 0 : k}
radius={r}
>
{card}
</Card>
))}
</View>
</Animated.ScrollView>
);
}
function Card({
children,
index,
count,
y,
top,
pin,
travel,
height,
depth,
radius,
}: {
children: ReactNode;
index: number;
count: number;
y: SharedValue<number>;
top: number;
pin: number;
travel: number;
height: number;
depth: number;
radius: number;
}) {
const { colors, scheme, shadow } = useKinetikTheme();
const ink = scheme === 'dark' ? colors.background : colors.text;
const frame = useAnimatedStyle(() => {
const past = y.get() - pin;
// Cards that have landed on this one since it pinned.
const covered = Math.max(0, Math.min(count - 1 - index, past / travel));
return {
transform: [{ translateY: Math.max(0, past) }, { scale: 1 - depth * covered }],
};
});
const shade = useAnimatedStyle(() => {
const covered = Math.max(0, Math.min(count - 1 - index, (y.get() - pin) / travel));
return { opacity: Math.min(0.55, covered * (depth > 0 ? 0.22 : 0)) };
});
return (
<Animated.View
style={[
styles.card,
{
top,
height,
borderRadius: radius,
backgroundColor: colors.surface,
boxShadow: shadow.lg,
},
frame,
]}
>
{children}
<Animated.View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: ink }, shade]}
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
card: {
position: 'absolute',
left: 0,
right: 0,
overflow: 'hidden',
transformOrigin: 'top',
},
});Installation
npx kinetik-ui add scroll-stackpnpm dlx kinetik-ui add scroll-stackbunx kinetik-ui add scroll-stackyarn dlx kinetik-ui add scroll-stackThe 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/scroll-stack.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/scroll-stack.tsx
Usage
import { ScrollStack } from '@/components/kinetik/scroll-stack';
<ScrollStack cardHeight={320} header={<Intro />} contentContainerStyle={{ padding: 16 }}>
{chapters.map((c) => (
<Chapter key={c.id} chapter={c} />
))}
</ScrollStack>;Give it a height (it fills its parent with flex: 1). Each child becomes a full-width card; header scrolls away above them.
When to use
- A short sequence read one after another: chapters, steps, features, plans.
- Landing and onboarding screens where the pile itself shows progress.
When not to use
- Long feeds. Cards stay mounted, and a deep pile stops reading as a stack.
- Cards of different heights; every card is
cardHeighttall.
Notes
peekis how much of each stacked card stays visible above the next, so the pile shows how far you have come.- Covered cards shrink by
depthper card on top of them and dim slightly. - The scroll indicator is hidden; the stack is its own progress.
Props
ScrollStackProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | The cards. Each child becomes one card of `cardHeight`. |
header | ReactNode | — | Scrolls away above the stack. |
cardHeight | number | 300 | Height of every card. Defaults to 300. |
gap | number | 24 | Space between cards before they stack. Defaults to 24. |
peek | number | 12 | How much of each stacked card stays visible above the next. Defaults to 12. |
topInset | number | 16 | Where the stack pins, from the top of the scroll view. Defaults to 16. |
depth | number | 0.06 | How much a covered card shrinks per card on top of it, 0–0.15. Defaults to 0.06. |
radius | number | — | Corner radius of the cards. Defaults to the theme `xl` radius. |
style | StyleProp<ViewStyle> | — | |
contentContainerStyle | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One scroll offset drives a transform and an overlay per card on the UI thread; nothing renders while scrolling.
- Measured
Real-device measurement pending.
Accessibility
- Reduced motion
- Cards still pin and stack, which is how the layout reads, but without shrinking or dimming.
- Screen readers
- A plain scroll view; cards are read in order and their content keeps its own roles.
- Touch target
- Cards are full width and `cardHeight` tall.
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/scroll-stack.tsx
import { Children, useState, type ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type ScrollStackProps = {
/** The cards. Each child becomes one card of `cardHeight`. */
children: ReactNode;
/** Scrolls away above the stack. */
header?: ReactNode;
/** Height of every card. Defaults to 300. */
cardHeight?: number;
/** Space between cards before they stack. Defaults to 24. */
gap?: number;
/** How much of each stacked card stays visible above the next. Defaults to 12. */
peek?: number;
/** Where the stack pins, from the top of the scroll view. Defaults to 16. */
topInset?: number;
/** How much a covered card shrinks per card on top of it, 0–0.15. Defaults to 0.06. */
depth?: number;
/** Corner radius of the cards. Defaults to the theme `xl` radius. */
radius?: number;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
};
/**
* A scroll view whose cards pin to the top and pile up: each one slides over
* the last, and the cards underneath shrink and dim a little more with every
* card that lands on them.
*/
export function ScrollStack({
children,
header,
cardHeight = 300,
gap = 24,
peek = 12,
topInset = 16,
depth = 0.06,
radius,
style,
contentContainerStyle,
}: ScrollStackProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const cards = Children.toArray(children);
const n = cards.length;
const r = radius ?? theme.radius.xl;
const k = Math.max(0, Math.min(0.15, depth));
const y = useSharedValue(0);
const [headerH, setHeaderH] = useState(0);
const [viewH, setViewH] = useState(0);
const onScroll = useAnimatedScrollHandler((e) => {
y.set(e.contentOffset.y);
});
// Room under the last card so it can scroll up to its pinned place.
const tail = Math.max(0, viewH - cardHeight - topInset - (n - 1) * peek);
const stackH = n * cardHeight + (n - 1) * gap + tail;
return (
<Animated.ScrollView
onScroll={onScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
onLayout={(e) => setViewH(e.nativeEvent.layout.height)}
style={[styles.root, style]}
contentContainerStyle={contentContainerStyle}
>
{header ? (
<View onLayout={(e) => setHeaderH(e.nativeEvent.layout.height)}>{header}</View>
) : null}
<View style={{ height: stackH }}>
{cards.map((card, i) => (
<Card
key={i}
index={i}
count={n}
y={y}
top={i * (cardHeight + gap)}
// Scroll position at which this card reaches its pinned place.
pin={headerH + i * (cardHeight + gap) - topInset - i * peek}
travel={cardHeight + gap - peek}
height={cardHeight}
depth={reduced ? 0 : k}
radius={r}
>
{card}
</Card>
))}
</View>
</Animated.ScrollView>
);
}
function Card({
children,
index,
count,
y,
top,
pin,
travel,
height,
depth,
radius,
}: {
children: ReactNode;
index: number;
count: number;
y: SharedValue<number>;
top: number;
pin: number;
travel: number;
height: number;
depth: number;
radius: number;
}) {
const { colors, scheme, shadow } = useKinetikTheme();
const ink = scheme === 'dark' ? colors.background : colors.text;
const frame = useAnimatedStyle(() => {
const past = y.get() - pin;
// Cards that have landed on this one since it pinned.
const covered = Math.max(0, Math.min(count - 1 - index, past / travel));
return {
transform: [{ translateY: Math.max(0, past) }, { scale: 1 - depth * covered }],
};
});
const shade = useAnimatedStyle(() => {
const covered = Math.max(0, Math.min(count - 1 - index, (y.get() - pin) / travel));
return { opacity: Math.min(0.55, covered * (depth > 0 ? 0.22 : 0)) };
});
return (
<Animated.View
style={[
styles.card,
{
top,
height,
borderRadius: radius,
backgroundColor: colors.surface,
boxShadow: shadow.lg,
},
frame,
]}
>
{children}
<Animated.View
pointerEvents="none"
style={[StyleSheet.absoluteFill, { backgroundColor: ink }, shade]}
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
root: { flex: 1 },
card: {
position: 'absolute',
left: 0,
right: 0,
overflow: 'hidden',
transformOrigin: 'top',
},
});