Компоненты/Параллакс-шапка

Параллакс-шапка

22 февраля 2026

Шапка с параллаксом при прокрутке

Установка

npx astra-rn add parallax-header
pnpm dlx astra-rn add parallax-header
yarn dlx astra-rn add parallax-header
bunx --bun astra-rn add parallax-header

Файлы

index.ts
НазадПараллакс-карусельДалееПараллакс-картинка

Вручную

Поставьте зависимости:

npm install expo-blur expo-linear-gradient
pnpm add expo-blur expo-linear-gradient
yarn add expo-blur expo-linear-gradient
bun add expo-blur expo-linear-gradient

Скопируйте эти файлы в src/shared/components/templates/parallax-header/:

Проверьте алиас путей. Импорты в коде выше записаны так, как их пишет astra-rn add: @/shared/components, @/shared/utils и @/shared/hooks. Нужен алиас "@/*": ["./src/*"] в tsconfig.json — или поправьте импорты под свои пути.

Использование

import React, { useEffect, useMemo, useRef } from "react";
import {
  Image,
  type ImageStyle,
  Platform,
  StatusBar,
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { Stack, useRouter, type Router } from "expo-router";
import { SymbolView } from "expo-symbols";

import {
  AnimatedScrollView,
  AnimatedScrollViewTitle,
  AnimatedScrollViewTitleWrapper,
  HeaderComponentWrapper,
  HeaderNavBar,
} from "@/shared/components/templates/parallax-header";
import { useAutoplay, useCustomize } from "~/showcase";
import { PHOTO_SOURCES } from "~/assets/photos";

const COVER =
  PHOTO_SOURCES["parallax-header-1"];

const COVER_FADE_MASK =
  "linear-gradient(to bottom, rgba(0,0,0,1), rgba(0,0,0,0.8), rgba(0,0,0,0.05))";
const WEB_COVER_FADE = {
  maskImage: COVER_FADE_MASK,
  WebkitMaskImage: COVER_FADE_MASK,
} as ImageStyle;

const ROWS = [1, 2, 3, 4, 5];
const ACCENT_HEX = "#EDEDED";

const CUSTOMIZE = [
  { key: "cover", label: "Обложка", type: "image", default: COVER },
  { key: "title", label: "Заголовок", type: "text", default: "Dua Lipa", maxLength: 28 },
  { key: "coverHeight", label: "Высота обложки", type: "slider", min: 240, max: 560, step: 10, unit: "px", default: 400 },
  { key: "topBar", label: "Высота панели", type: "slider", min: 60, max: 160, step: 2, unit: "px", default: 100 },
  { key: "rows", label: "Строк в списке", type: "slider", min: 1, max: 12, step: 1, default: ROWS.length },
  { key: "accent", label: "Цвет кнопки", type: "color", default: ACCENT_HEX },
  { key: "pull", label: "Оттяжка", type: "slider", min: 0, max: 240, step: 5, unit: "px", default: 120 },
  { key: "scrollTo", label: "Глубина прокрутки", type: "slider", min: 100, max: 800, step: 10, unit: "px", default: 300 },
] as const;
const TILES = [1, 2, 3];

function findScroller(host: HTMLElement | null) {
  if (!host) return null;
  return (
    Array.from(host.querySelectorAll<HTMLElement>("*")).find(
      (node) =>
        /auto|scroll/.test(getComputedStyle(node).overflowY) &&
        node.scrollHeight > node.clientHeight,
    ) ?? null
  );
}

function useWebDragScroll() {
  const hostRef = useRef<View>(null);

  useEffect(() => {
    if (Platform.OS !== "web") return;
    const host = hostRef.current as unknown as HTMLElement | null;
    if (!host) return;
    let scroller: HTMLElement | null = null;
    let startY = 0;
    let startTop = 0;
    let lastY = 0;
    let lastTime = 0;
    let velocity = 0;
    let frame = 0;
    const onDown = (event: MouseEvent) => {
      if (event.button !== 0) return;
      scroller = findScroller(host);
      if (!scroller) return;
      event.preventDefault();
      cancelAnimationFrame(frame);
      startY = lastY = event.clientY;
      startTop = Math.max(0, scroller.scrollTop);
      lastTime = performance.now();
      velocity = 0;
    };
    const onMove = (event: MouseEvent) => {
      if (!scroller) return;
      const now = performance.now();
      velocity = (event.clientY - lastY) / Math.max(1, now - lastTime);
      lastY = event.clientY;
      lastTime = now;
      scroller.scrollTop = startTop - (event.clientY - startY);
    };
    const onUp = () => {
      const target = scroller;
      scroller = null;
      if (!target) return;
      let speed = velocity * 16;
      const glide = () => {
        speed *= 0.95;
        if (Math.abs(speed) < 0.5) return;
        target.scrollTop -= speed;
        frame = requestAnimationFrame(glide);
      };
      frame = requestAnimationFrame(glide);
    };
    host.addEventListener("mousedown", onDown);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    return () => {
      cancelAnimationFrame(frame);
      host.removeEventListener("mousedown", onDown);
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
    };
  }, []);

  return hostRef;
}

function glideTo(
  scroller: HTMLElement,
  top: number,
  duration: number,
  isCancelled: () => boolean,
) {
  const from = scroller.scrollTop;
  const to = Math.min(top, scroller.scrollHeight - scroller.clientHeight);
  const start = performance.now();
  const step = (now: number) => {
    if (isCancelled()) return;
    const progress = Math.min(1, (now - start) / duration);
    const eased = 1 - Math.pow(1 - progress, 3);
    scroller.scrollTop = from + (to - from) * eased;
    if (progress < 1) requestAnimationFrame(step);
  };
  requestAnimationFrame(step);
}

const pulls = new WeakMap<HTMLElement, { value: number; frame: number }>();

const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3);
const easeInOutSine = (progress: number) =>
  -(Math.cos(Math.PI * progress) - 1) / 2;

function applyPull(scroller: HTMLElement, value: number) {
  const content = scroller.firstElementChild as HTMLElement | null;
  if (value > 0.5) {
    Object.defineProperty(scroller, "scrollTop", {
      configurable: true,
      get: () => -value,
      set: () => {},
    });
    if (content) content.style.transform = `translateY(${value}px)`;
  } else {
    delete (scroller as unknown as { scrollTop?: number }).scrollTop;
    if (content) content.style.transform = "";
  }
  scroller.dispatchEvent(new Event("scroll"));
}

function animatePull(
  scroller: HTMLElement,
  to: number,
  duration: number,
  ease: (progress: number) => number,
  isCancelled: () => boolean = () => false,
) {
  const state = pulls.get(scroller) ?? { value: 0, frame: 0 };
  pulls.set(scroller, state);
  cancelAnimationFrame(state.frame);
  const from = state.value;
  const start = performance.now();
  const step = (now: number) => {
    if (isCancelled()) {
      animatePull(scroller, 0, 260, easeOutCubic);
      return;
    }
    const progress = Math.min(1, (now - start) / duration);
    state.value = from + (to - from) * ease(progress);
    applyPull(scroller, state.value);
    if (progress < 1) state.frame = requestAnimationFrame(step);
  };
  state.frame = requestAnimationFrame(step);
}

export const HeaderDemo: React.FC = (): React.ReactNode => {
  const router: Router = useRouter() as Router;
  const hostRef = useWebDragScroll();
  const custom = useCustomize(CUSTOMIZE);
  const motion = useRef({ pull: custom.pull, scrollTo: custom.scrollTo });
  motion.current = { pull: custom.pull, scrollTo: custom.scrollTo };
  const coverHeight = custom.coverHeight;
  const coverStyle = useMemo(
    () => (coverHeight === 400 ? styles.cover : [styles.cover, { height: coverHeight }]),
    [coverHeight],
  );
  const overlayStyle = useMemo(
    () =>
      coverHeight === 400
        ? styles.coverOverlay
        : [styles.coverOverlay, { height: coverHeight }],
    [coverHeight],
  );
  const rows = useMemo(
    () =>
      custom.rows === ROWS.length
        ? ROWS
        : Array.from({ length: custom.rows }, (_, index) => index + 1),
    [custom.rows],
  );

  useAutoplay(async ({ wait, isCancelled }) => {
    const scroller = findScroller(
      hostRef.current as unknown as HTMLElement | null,
    );
    if (!scroller) {
      await wait(1000);
      return;
    }
    if (scroller.scrollTop > 1) {
      glideTo(scroller, 0, 700, isCancelled);
      await wait(900);
    }
    animatePull(scroller, motion.current.pull, 1000, easeInOutSine, isCancelled);
    await wait(1050);
    animatePull(scroller, 0, 420, easeOutCubic);
    await wait(520);
    glideTo(scroller, motion.current.scrollTo, 520, isCancelled);
    await wait(1500);
    glideTo(scroller, 0, 650, isCancelled);
    await wait(750);
  });

  return (
    <View ref={hostRef} style={styles.container}>
      <StatusBar
        barStyle="light-content"
        backgroundColor="transparent"
        translucent
      />
      <Stack.Screen options={{ headerShown: false }} />

      <AnimatedScrollView
        showsVerticalScrollIndicator={false}
        topBarHeight={custom.topBar}
        renderTopNavBarComponent={() => (
          <HeaderNavBar>
            <TouchableOpacity onPress={() => router.back()}>
              <SymbolView
                name="chevron.backward"
                size={18}
                tintColor="white"
                resizeMode="scaleAspectFit"
              />
            </TouchableOpacity>
            <Text style={styles.topNavTitle}>{custom.title}</Text>
            <TouchableOpacity>
              <SymbolView
                name="ellipsis"
                size={18}
                tintColor="white"
                resizeMode="scaleAspectFit"
              />
            </TouchableOpacity>
          </HeaderNavBar>
        )}
        renderOveralComponent={() => (
          <AnimatedScrollViewTitleWrapper>
            <></>
          </AnimatedScrollViewTitleWrapper>
        )}
        renderHeaderComponent={() => (
          <HeaderComponentWrapper useGradient={false}>
            {Platform.OS === "web" ? (
              <Image source={{ uri: custom.cover }} style={[coverStyle, WEB_COVER_FADE]} />
            ) : (
              <>
                <Image source={{ uri: custom.cover }} style={coverStyle} />
                <LinearGradient
                  colors={["transparent", "rgba(0,0,0,0.2)", "rgba(0,0,0,0.95)"]}
                  style={overlayStyle}
                />
              </>
            )}
          </HeaderComponentWrapper>
        )}
      >
        <View style={styles.content}>
          <View style={styles.meta}>
            <View style={[styles.bar, styles.barWide]} />
            <View style={[styles.bar, styles.barNarrow]} />
          </View>

          <View style={styles.actions}>
            <View
              style={[
                styles.pill,
                styles.pillPrimary,
                custom.accent !== ACCENT_HEX && { backgroundColor: custom.accent },
              ]}
            />
            <View style={styles.circle} />
            <View style={styles.circle} />
          </View>

          <View style={styles.list}>
            {rows.map((row) => (
              <View key={row} style={styles.row}>
                <View style={styles.thumb} />
                <View style={styles.rowLines}>
                  <View style={[styles.bar, styles.barWide]} />
                  <View style={[styles.bar, styles.barNarrow]} />
                </View>
                <View style={styles.rowEnd} />
              </View>
            ))}
          </View>

          <View style={styles.tiles}>
            {TILES.map((tile) => (
              <View key={tile} style={styles.tile} />
            ))}
          </View>

          <View style={styles.block} />
        </View>
      </AnimatedScrollView>
    </View>
  );
};

const SURFACE = "#131315";
const SURFACE_SOFT = "#232327";

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#000000",
  },
  topNavTitle: {
    color: "white",
    fontSize: 17,
    fontWeight: "600",
  },
  title: {
    color: "white",
    marginLeft: 10,
    maxWidth: 2000,
    letterSpacing: -1,
  },
  cover: {
    width: "100%",
    height: 400,
  },
  coverOverlay: {
    position: "absolute",
    top: 0,
    width: "100%",
    height: 400,
  },
  content: {
    flex: 1,
    paddingHorizontal: 20,
    paddingTop: 24,
    gap: 24,
  },
  meta: {
    gap: 10,
  },
  bar: {
    height: 10,
    borderRadius: 5,
    backgroundColor: SURFACE_SOFT,
  },
  barWide: {
    width: "58%",
  },
  barNarrow: {
    width: "32%",
    opacity: 0.6,
  },
  actions: {
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
  },
  pill: {
    height: 44,
    borderRadius: 22,
    backgroundColor: SURFACE,
  },
  pillPrimary: {
    flex: 1,
    backgroundColor: "#EDEDED",
  },
  circle: {
    width: 44,
    height: 44,
    borderRadius: 22,
    backgroundColor: SURFACE,
  },
  list: {
    gap: 18,
  },
  row: {
    flexDirection: "row",
    alignItems: "center",
    gap: 14,
  },
  thumb: {
    width: 48,
    height: 48,
    borderRadius: 10,
    backgroundColor: SURFACE,
  },
  rowLines: {
    flex: 1,
    gap: 8,
  },
  rowEnd: {
    width: 32,
    height: 10,
    borderRadius: 5,
    backgroundColor: SURFACE,
  },
  tiles: {
    flexDirection: "row",
    gap: 12,
  },
  tile: {
    flex: 1,
    aspectRatio: 1,
    borderRadius: 16,
    backgroundColor: SURFACE,
  },
  block: {
    height: 180,
    borderRadius: 24,
    backgroundColor: SURFACE,
    marginBottom: 40,
  },
});

export default HeaderDemo;

Пропсы

Пропс
Описание
imageHeight

Тип number

OverlayHeaderContent?

Тип React.ReactNode

translateYUp

Тип Animated.AnimatedInterpolation<string | number> | 0

translateYDown

Тип Animated.AnimatedInterpolation<string | number> | 0 | any

scale

Тип Animated.AnimatedInterpolation<string | number> | 1

imageStyle?

Тип StyleProp<ImageStyle>

HeaderComponent?

Тип JSX.Element

headerImage?

Тип ImageSourcePropType

AnimatedScrollViewTitleProps

Пропс
Описание
children

Тип React.ReactNode

size?

Тип number

style?

Тип StyleProp<TextStyle>

AnimatedHeaderProps

Пропс
Описание
imageHeight

Тип number

OverlayHeaderContent?

Тип React.ReactNode

translateYUp

Тип Animated.AnimatedInterpolation<string | number> | 0

translateYDown

Тип Animated.AnimatedInterpolation<string | number> | 0 | any

scale

Тип Animated.AnimatedInterpolation<string | number> | 1

imageStyle?

Тип StyleProp<ImageStyle>

HeaderComponent?

Тип JSX.Element

headerImage?

Тип ImageSourcePropType