skip to content

In React Native, how do you build a podcast header that hides on scroll down and returns on scroll up, and why does iOS bounce break a naive diffClamp?

level: seniorimportance: should knowfreq 28%

answer

  1. track the change, not the position
  2. clamp(value + diff, min, max)
  3. negative offsets while bouncing at the top
  4. clamp the input before diffClamp
  5. stickyHeaderHiddenOnScroll does it for you

basics

~20 s

Animated.diffClamp accumulates scroll deltas within 0 and the header height, so scrolling down hides the header and scrolling up reveals it. iOS bounce feeds it offsets outside the content, so clamp the scroll value first.

solid answer

~50 s

A hide-on-scroll header follows the direction of scrolling, not the absolute offset. `Animated.diffClamp(scrollY, 0, HEADER_HEIGHT)` does that: it adds each change to its own value and clamps it (`clamp(value + diff, min, max)`), so it starts moving as soon as the direction flips. Interpolate it to `translateY` from `0` to `-HEADER_HEIGHT`; with a native-driven `Animated.event` the whole chain runs on the UI thread. On iOS, where `bounces` defaults to `true`, pulling past the top gives negative offsets and the rebound back to zero reads as a scroll down, so the header half-hides at the top of the list; the end-of-list bounce can likewise reveal it. Clamp the input first, as React Native's own sticky header does with `extrapolateLeft: 'clamp'`, clamp the far end too if you use Reanimated, and snap a half-visible header when scrolling stops. For a sticky header inside the scroll content, `stickyHeaderHiddenOnScroll` implements this behaviour.

code

tsx · 25 lines
tsx
import { Animated, useAnimatedValue } from 'react-native';

const HEADER_HEIGHT = 96;

export function useHideOnScrollHeader() {
  const scrollY = useAnimatedValue(0);

  // Drop iOS's negative offsets from the top bounce before diffing.
  const nonNegativeY = scrollY.interpolate({
    inputRange: [0, 1],
    outputRange: [0, 1],
    extrapolateLeft: 'clamp',
  });

  const translateY = Animated.diffClamp(nonNegativeY, 0, HEADER_HEIGHT).interpolate({
    inputRange: [0, HEADER_HEIGHT],
    outputRange: [0, -HEADER_HEIGHT],
  });

  const onScroll = Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
    useNativeDriver: true,
  });

  return { translateY, onScroll };
}

go deeper

for a junior

Recall that a hide-on-scroll header follows scroll direction, and that Animated.diffClamp clamps an accumulated delta between 0 and the header height.

for a middle

Explain the diffClamp formula, interpolate it to translateY, and keep the chain native-driven on an Animated list.

for a senior

Anticipate iOS bounce at both ends, clamp the input, snap half-visible headers, and choose between diffClamp, stickyHeaderHiddenOnScroll and a Reanimated handler.

for a principal

Judge whether the hide-on-scroll pattern helps the screen at all, and standardise one implementation so every list behaves the same across platforms.

## The behaviour to build Many list screens hide their top bar while you read downwards and bring it back the moment you scroll up, without having to return to the top. On a podcast's episode list this keeps the filter bar out of the way while browsing and one flick away when needed. The key observation is that the header depends on the **direction and amount of recent scrolling**, not on the absolute offset. ## `Animated.diffClamp` React Native's `Animated` library has a node for exactly this: - `Animated.diffClamp(a, min, max)` creates a value limited to `[min, max]` that tracks the **difference** from the last input value: `value = clamp(value + diff, min, max)`. - Because it accumulates changes, it starts moving again as soon as the input changes direction, however far down the list you are. - The docs name this use directly: showing the navigation bar when scrolling up and hiding it when scrolling down. The usual chain: 1. `scrollY` receives `contentOffset.y` through `Animated.event(..., { useNativeDriver: true })` on an `Animated.FlatList`. 2. `Animated.diffClamp(scrollY, 0, HEADER_HEIGHT)` turns it into a value between 0 and the header height. 3. `.interpolate({ inputRange: [0, HEADER_HEIGHT], outputRange: [0, -HEADER_HEIGHT] })` becomes the header's `translateY`. The diff-clamp node exists in the native animated module, so the chain can run with the native driver, and `transform` is a property the native driver supports. ## Why iOS bounce breaks the naive version On iOS, `bounces` defaults to `true` (the prop is iOS-only). While the user pulls past the top, `contentOffset.y` goes **negative**; when they release, it springs back to 0. At the end of the content the scroll view similarly overshoots the maximum offset and comes back. | Moment | Raw offset change | What a naive diffClamp does | |---|---|---| | Pull down past the top | 0 to -80 | Pushes the value to 0: header fully shown | | Release, bounce back | -80 to 0 | Adds +80: header partly hidden at the top of the list | | Bounce at the end of the list | Past max, then back | Adds a negative diff: header reappears at the bottom | The result is a header that is half hidden when the list is at rest at the top, which users notice immediately. ## Fixes - **Clamp the input at the top.** Feed diffClamp a value that never goes below zero: `scrollY.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolateLeft: 'clamp' })`. React Native's own `ScrollViewStickyHeader` clamps its input with `extrapolateLeft: 'clamp'` before its diffClamp, for the same reason. - **Clamp the far end too** when you know the maximum offset. In Reanimated, the scroll event carries `contentSize` and `layoutMeasurement`, so the worklet can clamp `y` to `[0, contentSize.height - layoutMeasurement.height]` before computing the delta. - **Snap when scrolling stops.** A header left 40 % visible looks broken. With Reanimated, animate its shared value to fully shown or hidden in `onMomentumEnd` with `withTiming`; with core Animated the diffClamp node holds its own state, so snapping is harder, which is one reason teams move this effect to Reanimated. - **Or turn bouncing off** with `bounces={false}`, at the cost of the native iOS feel. Usually a last resort. ## The built-in alternative If the element that should hide is a **sticky header inside the scroll content** (listed in `stickyHeaderIndices`), set `stickyHeaderHiddenOnScroll`: the header hides when scrolling down and docks when scrolling up. Internally it uses `Animated.diffClamp` over a clamped input, which is a good sign the pattern above is the right one. A header that sits outside the list, such as a navigator header, still needs the manual version. ## A Reanimated version With `useAnimatedScrollHandler`, keep the previous clamped offset in the handler's `context` and move a `headerY` shared value by the negative delta, clamped with Reanimated's `clamp(value, -HEADER_HEIGHT, 0)`. This is the same diff-clamp idea written as code, which makes clamping both ends and snapping straightforward.

  • Why does React Native's stickyHeaderHiddenOnScroll not cover a header rendered by the navigator above the list?
    It applies to sticky headers inside the scroll content, the children listed in `stickyHeaderIndices`, which the `ScrollView` itself positions from its offset. A navigator header lives outside the scroll view, so you drive it yourself from the scroll offset with diffClamp or a Reanimated handler.
  • Why is snapping a half-visible header easier with Reanimated than with Animated.diffClamp?
    The diffClamp node keeps its accumulated value internally, so you cannot simply animate it to a target when scrolling stops. With Reanimated the header position is your own shared value, so `onMomentumEnd` can animate it to fully shown or hidden with `withTiming`.

saying these in an interview costs you the question

  • diffClamp tracks the absolute scroll offset, so the header only returns near the top.
  • iOS never reports negative contentOffset values, so no input clamping is needed.
  • Animated.diffClamp cannot run with the native driver.
  • Setting bounces={false} is the standard fix and has no user-visible cost.
  • stickyHeaderHiddenOnScroll works for any header, including one rendered by the navigator.