Kinetik UI

Scroll Stack

.md

A scroll view whose cards pin to the top and pile up, the ones beneath shrinking and dimming as more land.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add scroll-stack

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/scroll-stack.tsx
    View source files

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 cardHeight tall.

Notes

  • peek is how much of each stacked card stays visible above the next, so the pile shows how far you have come.
  • Covered cards shrink by depth per card on top of them and dim slightly.
  • The scroll indicator is hidden; the stack is its own progress.

Props

ScrollStackProps

PropTypeDefaultDescription
childrenrequiredReactNode—The cards. Each child becomes one card of `cardHeight`.
headerReactNode—Scrolls away above the stack.
cardHeightnumber300Height of every card. Defaults to 300.
gapnumber24Space between cards before they stack. Defaults to 24.
peeknumber12How much of each stacked card stays visible above the next. Defaults to 12.
topInsetnumber16Where the stack pins, from the top of the scroll view. Defaults to 16.
depthnumber0.06How much a covered card shrinks per card on top of it, 0–0.15. Defaults to 0.06.
radiusnumber—Corner radius of the cards. Defaults to the theme `xl` radius.
styleStyleProp<ViewStyle>—
contentContainerStyleStyleProp<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',
  },
});