# Parallax Header

> A scroll view whose header artwork scrolls at half speed, stretches on pull and hands off to a compact bar.

- Category: Navigation
- Version: 1.0.0
- Platforms: iOS, Android, web
- Dependencies: `react-native-reanimated`, `react-native-safe-area-context`, `react-native-worklets`
- Requires: React Native New Architecture (Reanimated 4)

## Install

```bash
npx kinetik-ui add parallax-header
```

With the shadcn CLI:

```bash
npx shadcn@latest add https://kinetik-ui.dev/r/parallax-header.json
```

## Usage

```tsx
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). Pass `0` when the component is not at the top of the screen.
- Use `barAccessory` for a share or close button that stays visible.

## ParallaxHeaderProps

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` |  | Scrollable content below the header. |
| `header` | `ReactNode` |  | Header artwork (image, gradient, illustration). Fills the header area. |
| `title` | `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>` |  |  |

## 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.

## Performance

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.

## Source

`components/kinetik/parallax-header.tsx`

```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' },
});
```
