Компоненты/Прогрессивное размытие

Прогрессивное размытие

28 сентября 2026

Плавно нарастающее размытие по краю прокручиваемой области, собранное из нескольких слоёв.

Установка

npx astra-rn add progressive-blur
pnpm dlx astra-rn add progressive-blur
yarn dlx astra-rn add progressive-blur
bunx --bun astra-rn add progressive-blur

Файлы

index.tsx
НазадПрогрессДалееВитрина проектов

Размытие, которое плавно усиливается к краю блока. Оно собрано из нескольких слоёв BlurView разной силы, каждый под своей градиентной маской, поэтому переход получается мягким, без видимой границы. Прикрепите его к любой стороне прокручиваемой области — контент будет уходить под него при прокрутке.

Внутрь можно положить свои элементы, например панель кнопок, и добавить затемнение к краю через tint. Размытие не перехватывает касания, так что список под ним продолжает листаться. На Android используется метод размытия dimezisBlurView, в вебе — backdrop-filter. Работает на iOS, Android и в вебе.

Вручную

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

npm install expo-blur expo-linear-gradient @react-native-masked-view/masked-view
pnpm add expo-blur expo-linear-gradient @react-native-masked-view/masked-view
yarn add expo-blur expo-linear-gradient @react-native-masked-view/masked-view
bun add expo-blur expo-linear-gradient @react-native-masked-view/masked-view

Скопируйте эти файлы в src/shared/components/molecules/progressive-blur/:

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

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

import React, { useMemo, useState } from "react";
import { Image, ScrollView, StyleSheet, Text, View, type LayoutChangeEvent } from "react-native";
import { ProgressiveBlur } from "@/shared/components/molecules/progressive-blur";
import { PHOTO_SOURCES, type PhotoName } from "~/assets/photos";
import { Showcase, useCustomize } from "~/showcase";

const PHOTOS: PhotoName[] = [
  "3d-carousel-architecture",
  "3d-carousel-bridge",
  "3d-carousel-building",
  "3d-carousel-city",
  "3d-carousel-cityscape",
  "3d-carousel-downtown",
  "3d-carousel-night",
  "3d-carousel-sky",
  "3d-carousel-sunset",
  "3d-carousel-winter",
  "3d-carousel-sunrise",
];

const ORDER = [1, 6, 2, 8, 3, 7, 4, 9, 5, 10, 11, 1, 6, 2];
const COLUMNS = 3;
const GAP = 2;
const TABS = ["Годы", "Месяцы", "Все"];

const CUSTOMIZE = [
  { key: "bottomBlur", label: "Размытие снизу", type: "slider", min: 0, max: 48, step: 1, unit: "px", default: 20 },
  { key: "bottomSize", label: "Высота зоны снизу", type: "slider", min: 40, max: 260, step: 4, unit: "px", default: 128 },
  { key: "showTop", label: "Размытие сверху", type: "toggle", default: true },
  { key: "topBlur", label: "Сила размытия сверху", type: "slider", min: 0, max: 48, step: 1, unit: "px", default: 18 },
  { key: "topSize", label: "Высота зоны сверху", type: "slider", min: 40, max: 260, step: 4, unit: "px", default: 96 },
  { key: "tinted", label: "Затемнение к краю", type: "toggle", default: false },
  { key: "tint", label: "Цвет затемнения", type: "color", default: "#000000" },
  { key: "height", label: "Высота ленты", type: "slider", min: 360, max: 800, step: 10, unit: "px", default: 600 },
] as const;

interface ICell {
  readonly row: number;
  readonly col: number;
  readonly span: number;
}

const placeCells = (count: number): { cells: ICell[]; rows: number } => {
  const taken = new Set<string>();
  const cells: ICell[] = [];
  let row = 0;
  let col = 0;
  let rows = 0;
  const free = (r: number, c: number, span: number) => {
    if (c + span > COLUMNS) return false;
    for (let dr = 0; dr < span; dr++) for (let dc = 0; dc < span; dc++) if (taken.has(`${r + dr}:${c + dc}`)) return false;
    return true;
  };
  for (let i = 0; i < count; i++) {
    const span = i % 7 === 0 ? 2 : 1;
    while (!free(row, col, span)) {
      col += 1;
      if (col >= COLUMNS) {
        col = 0;
        row += 1;
      }
    }
    for (let dr = 0; dr < span; dr++) for (let dc = 0; dc < span; dc++) taken.add(`${row + dr}:${col + dc}`);
    cells.push({ row, col, span });
    rows = Math.max(rows, row + span);
    col += span;
    if (col >= COLUMNS) {
      col = 0;
      row += 1;
    }
  }
  return { cells, rows };
};

const toRgba = (hex: string, alpha: number) => {
  const raw = hex.replace("#", "");
  const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw.slice(0, 6);
  const value = parseInt(full, 16);
  if (Number.isNaN(value)) return `rgba(0,0,0,${alpha})`;
  return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${alpha})`;
};

export default function ProgressiveBlurScreen() {
  const custom = useCustomize(CUSTOMIZE);
  const [width, setWidth] = useState(0);
  const tint = custom.tinted ? toRgba(custom.tint, 0.6) : undefined;
  const layout = useMemo(() => placeCells(ORDER.length), []);
  const unit = width > 0 ? (width - GAP * (COLUMNS - 1)) / COLUMNS : 0;

  return (
    <Showcase>
      <View style={styles.container}>
        <View style={styles.frame}>
          <View
            onLayout={(e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)}
            style={[styles.viewport, { height: custom.height }]}
          >
            <ScrollView showsVerticalScrollIndicator={false}>
              <View style={{ height: layout.rows * unit + (layout.rows - 1) * GAP }}>
                {unit > 0
                  ? ORDER.map((n, index) => {
                      const cell = layout.cells[index]!;
                      const side = unit * cell.span + GAP * (cell.span - 1);
                      return (
                        <Image
                          key={index}
                          source={{ uri: PHOTO_SOURCES[PHOTOS[n - 1]!] }}
                          resizeMode="cover"
                          style={{
                            position: "absolute",
                            left: cell.col * (unit + GAP),
                            top: cell.row * (unit + GAP),
                            width: side,
                            height: side,
                          }}
                        />
                      );
                    })
                  : null}
              </View>
            </ScrollView>
            {custom.showTop ? (
              <ProgressiveBlur side="top" size={custom.topSize} blur={custom.topBlur} tint={tint} />
            ) : null}
            <ProgressiveBlur side="bottom" size={custom.bottomSize} blur={custom.bottomBlur} tint={tint}>
              <View pointerEvents="box-none" style={styles.tabsWrap}>
                <View style={styles.tabs}>
                  {TABS.map((tab, index) => (
                    <View key={tab} style={[styles.tab, index === 0 ? styles.tabActive : null]}>
                      <Text style={[styles.tabText, index === 0 ? styles.tabTextActive : null]}>{tab}</Text>
                    </View>
                  ))}
                </View>
              </View>
            </ProgressiveBlur>
          </View>
        </View>
      </View>
    </Showcase>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0a0a0a",
    alignItems: "center",
    justifyContent: "center",
    paddingHorizontal: 24,
    paddingVertical: 32,
  },
  frame: {
    width: "100%",
    maxWidth: 384,
    borderRadius: 32,
    borderWidth: 1,
    borderColor: "rgba(255,255,255,0.1)",
    shadowColor: "#000",
    shadowOpacity: 0.35,
    shadowRadius: 40,
    shadowOffset: { width: 0, height: 24 },
  },
  viewport: {
    width: "100%",
    borderRadius: 31,
    overflow: "hidden",
    backgroundColor: "#09090b",
  },
  tabsWrap: {
    flex: 1,
    alignItems: "center",
    justifyContent: "flex-end",
    paddingBottom: 16,
  },
  tabs: {
    flexDirection: "row",
    alignItems: "center",
    gap: 4,
    borderRadius: 999,
    padding: 4,
    backgroundColor: "rgba(24,24,27,0.7)",
    borderWidth: 1,
    borderColor: "rgba(255,255,255,0.1)",
    shadowColor: "#000",
    shadowOpacity: 0.4,
    shadowRadius: 15,
    shadowOffset: { width: 0, height: 8 },
  },
  tab: {
    borderRadius: 999,
    paddingHorizontal: 14,
    paddingVertical: 6,
  },
  tabActive: {
    backgroundColor: "#ffffff",
  },
  tabText: {
    fontSize: 13,
    lineHeight: 18,
    fontWeight: "500",
    color: "#e4e4e7",
  },
  tabTextActive: {
    color: "#18181b",
  },
});

Положите компонент поверх ScrollView в общем контейнере — он сам встанет к выбранному краю.

Пропсы

Пропс
Описание
side?

Тип ProgressiveBlurSide

size?

Тип number | `${number}%`

blur?

Тип number

tint?

Тип string

blurTint?

Тип "dark" | "light" | "default"

style?

Тип StyleProp<ViewStyle>

children?

Тип ReactNode