Parallax Header
A scroll view whose header artwork scrolls at half speed, stretches on pull and hands off to a compact bar.
import type { ReactNode } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
Extrapolation,
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type ParallaxHeaderProps = {
/** Scrollable content below the header. */
children: ReactNode;
/** Header artwork (image, gradient, illustration). Fills the header area. */
header: ReactNode;
/** Large title shown over the artwork and in the compact bar once scrolled. */
title: string;
/** Small line under the large title. */
subtitle?: string;
/** Height of the expanded header, in pt. Defaults to 300. */
headerHeight?: number;
/** Height of the compact bar below the status bar, in pt. Defaults to 52. */
barHeight?: number;
/** Distance from the top of the screen to the bar. Defaults to the safe-area inset. */
topInset?: number;
/** Content shown at the right of the compact bar (e.g. a button). */
barAccessory?: ReactNode;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
};
/**
* A scroll view with a layered header: the artwork scrolls at half speed and
* stretches when pulled down, the large title fades as it leaves, and a
* compact bar with the title takes over at the top.
*/
export function ParallaxHeader({
children,
header,
title,
subtitle,
headerHeight = 300,
barHeight = 52,
topInset,
barAccessory,
style,
contentContainerStyle,
}: ParallaxHeaderProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const insets = useSafeAreaInsets();
const top = topInset ?? insets.top;
const ref = useAnimatedRef<Animated.ScrollView>();
const offset = useScrollOffset(ref);
// The compact bar takes over once the large title has scrolled under it.
const collapseAt = headerHeight - top - barHeight;
const artwork = useAnimatedStyle(() => {
const y = offset.get();
if (y < 0) {
// Pulled down (iOS bounce): stretch from the top edge.
const scale = 1 + -y / headerHeight;
return { transform: [{ translateY: y / 2 }, { scale }] };
}
return { transform: [{ translateY: reduced ? -y : -y * 0.5 }] };
});
const largeTitle = useAnimatedStyle(() => {
const y = offset.get();
return {
opacity: interpolate(y, [0, collapseAt * 0.7], [1, 0], Extrapolation.CLAMP),
transform: [
{
translateY: reduced
? 0
: interpolate(y, [-100, 0, collapseAt], [24, 0, -24], Extrapolation.CLAMP),
},
],
};
});
const bar = useAnimatedStyle(() => {
const t = interpolate(offset.get(), [collapseAt - 24, collapseAt], [0, 1], Extrapolation.CLAMP);
return { opacity: t };
});
const barTitle = useAnimatedStyle(() => {
const t = interpolate(
offset.get(),
[collapseAt - 8, collapseAt + 16],
[0, 1],
Extrapolation.CLAMP,
);
return { opacity: t, transform: [{ translateY: reduced ? 0 : (1 - t) * 8 }] };
});
return (
<View style={[styles.root, { backgroundColor: theme.colors.background }, style]}>
<Animated.View
pointerEvents="none"
style={[styles.artwork, { height: headerHeight }, artwork]}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
>
{header}
</Animated.View>
<Animated.ScrollView
ref={ref}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
// Lets it scroll when embedded in another scroll view on Android.
nestedScrollEnabled
contentContainerStyle={contentContainerStyle}
>
<View style={[styles.hero, { height: headerHeight, padding: theme.space[5] }]}>
<Animated.View style={largeTitle}>
<Text
accessibilityRole="header"
style={{
color: theme.colors.text,
fontSize: theme.font.size['3xl'],
fontWeight: theme.font.weight.heavy,
letterSpacing: -1,
}}
>
{title}
</Text>
{subtitle ? (
<Text
style={{
color: theme.colors.textMuted,
fontSize: theme.font.size.md,
marginTop: 4,
}}
>
{subtitle}
</Text>
) : null}
</Animated.View>
</View>
<View style={{ backgroundColor: theme.colors.background }}>{children}</View>
</Animated.ScrollView>
<Animated.View
pointerEvents="box-none"
style={[
styles.bar,
{
paddingTop: top,
height: top + barHeight,
backgroundColor: theme.colors.surface,
borderColor: theme.colors.border,
},
bar,
]}
>
<Animated.Text
numberOfLines={1}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
style={[
styles.barTitle,
{
color: theme.colors.text,
fontSize: theme.font.size.md,
fontWeight: theme.font.weight.bold,
},
barTitle,
]}
>
{title}
</Animated.Text>
</Animated.View>
{barAccessory ? (
<View style={[styles.accessory, { top, height: barHeight, right: theme.space[3] }]}>
{barAccessory}
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, overflow: 'hidden' },
artwork: { position: 'absolute', top: 0, left: 0, right: 0, overflow: 'hidden' },
hero: { justifyContent: 'flex-end' },
bar: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
justifyContent: 'center',
borderBottomWidth: StyleSheet.hairlineWidth,
},
barTitle: { textAlign: 'center', paddingHorizontal: 64 },
accessory: { position: 'absolute', justifyContent: 'center' },
});Installation
npx kinetik-ui add parallax-headerpnpm dlx kinetik-ui add parallax-headerbunx kinetik-ui add parallax-headeryarn dlx kinetik-ui add parallax-headerThe 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/parallax-header.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-safe-area-context react-native-worklets - Copy the source files
components/kinetik/parallax-header.tsx
Usage
import { ParallaxHeader } from '@/components/kinetik/parallax-header';
<ParallaxHeader
title="Long Exposure"
subtitle="Lumen Fields · 10 tracks"
header={<Image source={cover} style={StyleSheet.absoluteFill} />}
>
<TrackList />
</ParallaxHeader>;ParallaxHeader is the scroll view: put the screen's content inside it. The artwork fills headerHeight and should cover its area (StyleSheet.absoluteFill).
When to use
- Detail screens led by an image: albums, places, profiles, products.
- Screens where the title should stay reachable after the hero scrolls away.
When not to use
- Lists without a meaningful hero; a plain header is quieter.
- Screens that already use a native large-title navigation bar. Pick one.
Notes
- The stretch on pull-down needs bounce scrolling (iOS); Android scrolls without it.
- The compact bar starts below
topInset(the safe-area top by default). Pass0when the component is not at the top of the screen. - Use
barAccessoryfor a share or close button that stays visible.
Props
ParallaxHeaderProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | Scrollable content below the header. |
headerrequired | ReactNode | — | Header artwork (image, gradient, illustration). Fills the header area. |
titlerequired | string | — | Large title shown over the artwork and in the compact bar once scrolled. |
subtitle | string | — | Small line under the large title. |
headerHeight | number | 300 | Height of the expanded header, in pt. Defaults to 300. |
barHeight | number | 52 | Height of the compact bar below the status bar, in pt. Defaults to 52. |
topInset | number | — | Distance from the top of the screen to the bar. Defaults to the safe-area inset. |
barAccessory | ReactNode | — | Content shown at the right of the compact bar (e.g. a button). |
style | StyleProp<ViewStyle> | — | |
contentContainerStyle | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- The scroll offset is read on the UI thread; four style worklets (artwork, title, bar, bar title) run per scroll frame. No JS work while scrolling.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.00× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 144 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- The artwork scrolls with the content instead of at half speed; titles fade without moving.
- Screen readers
- The large title is a header; the compact bar's copy of it is hidden to avoid reading it twice. Artwork is decorative.
- Touch target
- Not interactive itself; barAccessory 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/parallax-header.tsx
import type { ReactNode } from 'react';
import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';
import Animated, {
Extrapolation,
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type ParallaxHeaderProps = {
/** Scrollable content below the header. */
children: ReactNode;
/** Header artwork (image, gradient, illustration). Fills the header area. */
header: ReactNode;
/** Large title shown over the artwork and in the compact bar once scrolled. */
title: string;
/** Small line under the large title. */
subtitle?: string;
/** Height of the expanded header, in pt. Defaults to 300. */
headerHeight?: number;
/** Height of the compact bar below the status bar, in pt. Defaults to 52. */
barHeight?: number;
/** Distance from the top of the screen to the bar. Defaults to the safe-area inset. */
topInset?: number;
/** Content shown at the right of the compact bar (e.g. a button). */
barAccessory?: ReactNode;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
};
/**
* A scroll view with a layered header: the artwork scrolls at half speed and
* stretches when pulled down, the large title fades as it leaves, and a
* compact bar with the title takes over at the top.
*/
export function ParallaxHeader({
children,
header,
title,
subtitle,
headerHeight = 300,
barHeight = 52,
topInset,
barAccessory,
style,
contentContainerStyle,
}: ParallaxHeaderProps) {
const theme = useKinetikTheme();
const reduced = useReduceMotion();
const insets = useSafeAreaInsets();
const top = topInset ?? insets.top;
const ref = useAnimatedRef<Animated.ScrollView>();
const offset = useScrollOffset(ref);
// The compact bar takes over once the large title has scrolled under it.
const collapseAt = headerHeight - top - barHeight;
const artwork = useAnimatedStyle(() => {
const y = offset.get();
if (y < 0) {
// Pulled down (iOS bounce): stretch from the top edge.
const scale = 1 + -y / headerHeight;
return { transform: [{ translateY: y / 2 }, { scale }] };
}
return { transform: [{ translateY: reduced ? -y : -y * 0.5 }] };
});
const largeTitle = useAnimatedStyle(() => {
const y = offset.get();
return {
opacity: interpolate(y, [0, collapseAt * 0.7], [1, 0], Extrapolation.CLAMP),
transform: [
{
translateY: reduced
? 0
: interpolate(y, [-100, 0, collapseAt], [24, 0, -24], Extrapolation.CLAMP),
},
],
};
});
const bar = useAnimatedStyle(() => {
const t = interpolate(offset.get(), [collapseAt - 24, collapseAt], [0, 1], Extrapolation.CLAMP);
return { opacity: t };
});
const barTitle = useAnimatedStyle(() => {
const t = interpolate(
offset.get(),
[collapseAt - 8, collapseAt + 16],
[0, 1],
Extrapolation.CLAMP,
);
return { opacity: t, transform: [{ translateY: reduced ? 0 : (1 - t) * 8 }] };
});
return (
<View style={[styles.root, { backgroundColor: theme.colors.background }, style]}>
<Animated.View
pointerEvents="none"
style={[styles.artwork, { height: headerHeight }, artwork]}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
>
{header}
</Animated.View>
<Animated.ScrollView
ref={ref}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
// Lets it scroll when embedded in another scroll view on Android.
nestedScrollEnabled
contentContainerStyle={contentContainerStyle}
>
<View style={[styles.hero, { height: headerHeight, padding: theme.space[5] }]}>
<Animated.View style={largeTitle}>
<Text
accessibilityRole="header"
style={{
color: theme.colors.text,
fontSize: theme.font.size['3xl'],
fontWeight: theme.font.weight.heavy,
letterSpacing: -1,
}}
>
{title}
</Text>
{subtitle ? (
<Text
style={{
color: theme.colors.textMuted,
fontSize: theme.font.size.md,
marginTop: 4,
}}
>
{subtitle}
</Text>
) : null}
</Animated.View>
</View>
<View style={{ backgroundColor: theme.colors.background }}>{children}</View>
</Animated.ScrollView>
<Animated.View
pointerEvents="box-none"
style={[
styles.bar,
{
paddingTop: top,
height: top + barHeight,
backgroundColor: theme.colors.surface,
borderColor: theme.colors.border,
},
bar,
]}
>
<Animated.Text
numberOfLines={1}
importantForAccessibility="no-hide-descendants"
accessibilityElementsHidden
style={[
styles.barTitle,
{
color: theme.colors.text,
fontSize: theme.font.size.md,
fontWeight: theme.font.weight.bold,
},
barTitle,
]}
>
{title}
</Animated.Text>
</Animated.View>
{barAccessory ? (
<View style={[styles.accessory, { top, height: barHeight, right: theme.space[3] }]}>
{barAccessory}
</View>
) : null}
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, overflow: 'hidden' },
artwork: { position: 'absolute', top: 0, left: 0, right: 0, overflow: 'hidden' },
hero: { justifyContent: 'flex-end' },
bar: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
justifyContent: 'center',
borderBottomWidth: StyleSheet.hairlineWidth,
},
barTitle: { textAlign: 'center', paddingHorizontal: 64 },
accessory: { position: 'absolute', justifyContent: 'center' },
});