Kinetik UI

Fade Scroll

.md

A scroll view whose edges fade only where there is more to see, with a slim indicator that slips away.

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

Installation

Kinetik CLI
npx kinetik-ui add fade-scroll

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/fade-scroll.json

Install manually

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

Usage

import { FadeScroll } from '@/components/kinetik/fade-scroll';

<FadeScroll style={{ height: 260 }} accessibilityLabel="Privacy summary">
  {sections}
</FadeScroll>;

When to use

  • A scrolling area inside a screen, such as terms in a sheet, a list in a card or a row of chips, where people need a hint that there is more.
  • Places where a hard cut-off edge would look like the content ends there.

When not to use

  • The main scroll view of a screen. The screen edges already show that it scrolls.
  • Content on a busy background such as a photo. The fades need a flat colour to fade into.

Notes

  • An edge fade appears only when there is content beyond that edge, and grows over the first fadeSize points of scrolling.
  • Set fadeColor to the colour behind the scroll view (it defaults to the theme background). #RRGGBB colours work best.
  • The slim indicator shows while scrolling and slips away 0.7 s after it stops. With reduced motion the platform's own indicator is used instead.

Props

FadeScrollProps

PropTypeDefaultDescription
childrenrequiredReactNode—
horizontalbooleanfalseScroll sideways instead of down.
fadeSizenumber32Length of each edge fade. Defaults to 32.
fadeColorstring—The colour behind the scroll view, which the edges fade into. Defaults to the theme background.
indicatorbooleantrueA slim indicator that shows while scrolling. Defaults to true.
accessibilityLabelstring—Read by screen readers, e.g. "Terms of service".
testIDstring—
styleStyleProp<ViewStyle>—Give it a height (or a width when horizontal), or it grows with its content.
contentContainerStyleStyleProp<ViewStyle>—

Performance and accessibility

Performance

Budget
A native scroll view with two gradient views and a bar that read the scroll offset on the UI thread. Nothing runs at rest.
Measured

Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 135 MB app memory

Real-device measurement pending.

Accessibility

Reduced motion
The animated indicator is replaced by the platform's own.
Screen readers
A plain scroll view named by accessibilityLabel; the fades are decoration.
Touch target
Not interactive itself; its content keeps its own targets.

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/fade-scroll.tsx
import { type ReactNode } from 'react';
import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withTiming,
} from 'react-native-reanimated';

import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, easings } from '../../lib/kinetik/motion/motion';
import { gradient } from '../../lib/kinetik/tokens/gradient';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { withAlpha } from '../../lib/kinetik/tokens/tokens';

export type FadeScrollProps = {
  children: ReactNode;
  /** Scroll sideways instead of down. */
  horizontal?: boolean;
  /** Length of each edge fade. Defaults to 32. */
  fadeSize?: number;
  /** The colour behind the scroll view, which the edges fade into. Defaults to the theme background. */
  fadeColor?: string;
  /** A slim indicator that shows while scrolling. Defaults to true. */
  indicator?: boolean;
  /** Read by screen readers, e.g. "Terms of service". */
  accessibilityLabel?: string;
  testID?: string;
  /** Give it a height (or a width when horizontal), or it grows with its content. */
  style?: StyleProp<ViewStyle>;
  contentContainerStyle?: StyleProp<ViewStyle>;
};

const BAR = 3;
const INSET = 3;

/**
 * A scroll view whose edges fade only where there is more to see. At the top
 * there is no top fade; scroll a little and it fades in, reach the end and the
 * bottom fade goes. A slim indicator appears while scrolling and slips away
 * when it stops.
 */
export function FadeScroll({
  children,
  horizontal = false,
  fadeSize = 32,
  fadeColor,
  indicator = true,
  accessibilityLabel,
  testID,
  style,
  contentContainerStyle,
}: FadeScrollProps) {
  const { colors } = useKinetikTheme();
  const reduced = useReduceMotion();
  const ground = fadeColor ?? colors.background;
  const offset = useSharedValue(0);
  const content = useSharedValue(0);
  const frame = useSharedValue(0);
  const shown = useSharedValue(0);

  const hide = () => {
    'worklet';
    shown.set(withDelay(700, withTiming(0, { duration: durations.slow, easing: easings.exit })));
  };
  const onScroll = useAnimatedScrollHandler({
    onScroll: (e) => {
      offset.set(horizontal ? e.contentOffset.x : e.contentOffset.y);
      content.set(horizontal ? e.contentSize.width : e.contentSize.height);
      frame.set(horizontal ? e.layoutMeasurement.width : e.layoutMeasurement.height);
      if (indicator) shown.set(1);
    },
    onEndDrag: () => hide(),
    onMomentumEnd: () => hide(),
  });

  const start = useAnimatedStyle(() => ({
    opacity: Math.min(1, Math.max(0, offset.get() / fadeSize)),
  }));
  const end = useAnimatedStyle(() => {
    const left = content.get() - frame.get() - offset.get();
    return { opacity: content.get() > frame.get() ? Math.min(1, Math.max(0, left / fadeSize)) : 0 };
  });
  const bar = useAnimatedStyle(() => {
    const total = content.get();
    const view = frame.get();
    if (total <= view || view === 0) return { opacity: 0 };
    const len = Math.max(24, (view / total) * (view - INSET * 2));
    const travel = view - INSET * 2 - len;
    const at = INSET + Math.min(1, Math.max(0, offset.get() / (total - view))) * travel;
    return horizontal
      ? { opacity: shown.get(), width: len, transform: [{ translateX: at }] }
      : { opacity: shown.get(), height: len, transform: [{ translateY: at }] };
  });

  const solid = ground;
  const clear = withAlpha(ground, 0);
  const startFade = gradient(
    `linear-gradient(${horizontal ? 'to right' : 'to bottom'}, ${solid}, ${clear})`,
  );
  const endFade = gradient(
    `linear-gradient(${horizontal ? 'to left' : 'to top'}, ${solid}, ${clear})`,
  );
  const edge = horizontal ? { width: fadeSize } : { height: fadeSize };

  return (
    <View testID={testID} style={[styles.root, style]}>
      <Animated.ScrollView
        horizontal={horizontal}
        onScroll={onScroll}
        scrollEventThrottle={16}
        onContentSizeChange={(w, h) => content.set(horizontal ? w : h)}
        onLayout={(e) =>
          frame.set(horizontal ? e.nativeEvent.layout.width : e.nativeEvent.layout.height)
        }
        accessibilityLabel={accessibilityLabel}
        contentContainerStyle={contentContainerStyle}
        // With reduced motion the native indicator stands in for the animated one.
        showsVerticalScrollIndicator={reduced && !horizontal}
        showsHorizontalScrollIndicator={reduced && horizontal}
      >
        {children}
      </Animated.ScrollView>
      <Animated.View
        pointerEvents="none"
        style={[styles.edge, horizontal ? styles.left : styles.top, edge, startFade, start]}
      />
      <Animated.View
        pointerEvents="none"
        style={[styles.edge, horizontal ? styles.right : styles.bottom, edge, endFade, end]}
      />
      {indicator && !reduced ? (
        <Animated.View
          pointerEvents="none"
          style={[
            styles.bar,
            horizontal ? styles.barX : styles.barY,
            { backgroundColor: colors.textSubtle },
            bar,
          ]}
        />
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  root: { overflow: 'hidden' },
  edge: { position: 'absolute' },
  top: { top: 0, left: 0, right: 0 },
  bottom: { bottom: 0, left: 0, right: 0 },
  left: { left: 0, top: 0, bottom: 0 },
  right: { right: 0, top: 0, bottom: 0 },
  bar: { position: 'absolute', borderRadius: BAR },
  barY: { right: 2, top: 0, width: BAR },
  barX: { bottom: 2, left: 0, height: BAR },
});