Компоненты/Список дугой

Список дугой

21 августа 2026

Список, элементы которого изгибаются по дуге при прокрутке

Установка

npx astra-rn add arc-list
pnpm dlx astra-rn add arc-list
yarn dlx astra-rn add arc-list
bunx --bun astra-rn add arc-list

Файлы

index.tsx
НазадАнимированный список выбораДалееБегущая дуга

Вручную

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

npm install expo-haptics react-native-reanimated react-native-worklets
pnpm add expo-haptics react-native-reanimated react-native-worklets
yarn add expo-haptics react-native-reanimated react-native-worklets
bun add expo-haptics react-native-reanimated react-native-worklets

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

Он импортирует ещё один модуль — astra-rn add кладёт их рядом сам, вручную скопируйте и их:

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

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

import React, { useEffect, useMemo, useRef, useState } from "react";
import { Platform, StyleSheet, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaView } from "react-native-safe-area-context";

import { ArcList } from "@/shared/components/molecules/arc-list";
import { LinearGradient } from "expo-linear-gradient";
import { useFonts } from "expo-font";
import { Showcase, useAutoplay, useCustomize } from "~/showcase";

const CUSTOMIZE = [
  { key: "labelColor", label: "Цвет пунктов", type: "color", default: "#c9c9c9" },
  { key: "activeColor", label: "Активный пункт", type: "color", default: "#ffffff" },
  { key: "cardColor", label: "Фон карточки", type: "color", default: "#000000" },
  { key: "fontSize", label: "Размер шрифта", type: "slider", min: 12, max: 28, step: 1, unit: "px", default: 19 },
  { key: "radius", label: "Радиус дуги", type: "slider", min: 120, max: 1200, step: 10, unit: "px", default: 360 },
  { key: "minOpacity", label: "Мин. прозрачность", type: "slider", min: 0, max: 1, step: 0.05, default: 0.5 },
  { key: "minScale", label: "Мин. масштаб", type: "slider", min: 0.4, max: 1, step: 0.02, default: 0.78 },
  { key: "side", label: "Сторона", type: "select", options: [{ value: "left", label: "Слева" }, { value: "right", label: "Справа" }], default: "left" },
  { key: "pause", label: "Пауза автопоказа", type: "slider", min: 0.25, max: 3, step: 0.05, default: 1 },
] as const;
const MOTTOS = [
  "Become",
  "Wander",
  "Breathe",
  "Awaken",
  "Create",
  "Remember",
  "Begin Again",
  "Stay Wild",
  "Keep Wonder",
  "Follow The Light",
  "Lost & Found",
  "Into The Unknown",
  "Nothing Lasts",
  "Here & Now",
  "After The Rain",
  "Let It Flow",
  "Find Your Way",
  "Beyond The Ordinary",
  "Until The End",
  "While We Can",
];

const CARD_HEIGHT = 340;

const _width = 268;
const CARD_BG = "#000";
const FADE_HEIGHT = 120;

const FADE_STOPS = [1, 0.98, 0.92, 0.8, 0.62, 0.42, 0.24, 0.1, 0.03, 0];
const fade = (alpha: number) => `rgba(5, 5, 5, ${alpha})`;
const TOP_FADE = FADE_STOPS.map(fade) as [string, string, ...string[]];
const BOTTOM_FADE = [...FADE_STOPS].reverse().map(fade) as [
  string,
  string,
  ...string[],
];
const FADE_LOCATIONS = FADE_STOPS.map((_, i) => i / (FADE_STOPS.length - 1));
const ITEM_HEIGHT = 44;
const TOUR = [
  { index: 12, hold: 250 },
  { index: 9, hold: 300 },
  { index: 5, hold: 350 },
  { index: 19, hold: 450 },
  { index: 10, hold: 1000 },
  { index: 4, hold: 250 },
  { index: 0, hold: 200 },
  { index: 8, hold: 700 },
];

function glideTo(
  scroller: HTMLElement,
  top: number,
  isCancelled: () => boolean,
) {
  const from = scroller.scrollTop;
  const duration = Math.min(750, 300 + Math.abs(top - from) * 0.4);
  const start = performance.now();
  return new Promise<void>((resolve) => {
    const step = (now: number) => {
      if (isCancelled()) {
        resolve();
        return;
      }
      const progress = Math.min(1, (now - start) / duration);
      scroller.scrollTop = from + (top - from) * (1 - Math.pow(1 - progress, 4));
      if (progress < 1) requestAnimationFrame(step);
      else resolve();
    };
    requestAnimationFrame(step);
  });
}

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(itemHeight: number) {
  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;
    let owned: HTMLElement | null = null;
    const settle = (target: HTMLElement) => {
      owned = null;
      const top = Math.round(target.scrollTop / itemHeight) * itemHeight;
      Element.prototype.scrollTo.call(target, { top, behavior: "smooth" });
    };
    const onScrollEnd = (event: Event) => {
      if (owned && event.target === owned) event.stopImmediatePropagation();
    };
    const onDown = (event: MouseEvent) => {
      if (event.button !== 0) return;
      scroller = findScroller(host);
      if (!scroller) return;
      event.preventDefault();
      cancelAnimationFrame(frame);
      owned = scroller;
      startY = lastY = event.clientY;
      startTop = 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.92;
        if (Math.abs(speed) < 0.5) {
          settle(target);
          return;
        }
        target.scrollTop -= speed;
        frame = requestAnimationFrame(glide);
      };
      frame = requestAnimationFrame(glide);
    };
    host.addEventListener("mousedown", onDown);
    window.addEventListener("scrollend", onScrollEnd, true);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    return () => {
      cancelAnimationFrame(frame);
      host.removeEventListener("mousedown", onDown);
      window.removeEventListener("scrollend", onScrollEnd, true);
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
    };
  }, [itemHeight]);

  return hostRef;
}

export default function ArcListScreen() {
  const [fontLoaded] = useFonts({
    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),
  });
  const hostRef = useWebDragScroll(ITEM_HEIGHT);
  const custom = useCustomize(CUSTOMIZE);
  const pauseRef = useRef<number>(custom.pause);
  pauseRef.current = custom.pause;
  const { labelColor, activeColor, fontSize } = custom;
  const rows = useMemo(
    () =>
      MOTTOS.map((life_motto) => (
        <ArcList.Item key={life_motto}>
          <ArcList.Label
            color={labelColor}
            activeColor={activeColor}
            style={[styles.label, { fontSize }]}
          >
            {life_motto}
          </ArcList.Label>
        </ArcList.Item>
      )),
    [labelColor, activeColor, fontSize],
  );

  useAutoplay(async ({ wait, isCancelled }) => {
    for (const step of TOUR) {
      await wait(step.hold * pauseRef.current);
      const scroller = findScroller(
        hostRef.current as unknown as HTMLElement | null,
      );
      if (!scroller) continue;
      await glideTo(scroller, step.index * ITEM_HEIGHT, isCancelled);
    }
  });

  return (
    <Showcase>
      <SafeAreaView style={styles.container}>
        <View style={styles.stage}>
          <View
            ref={hostRef}
            style={[styles.card, { backgroundColor: custom.cardColor }]}
          >
            <LinearGradient
              pointerEvents="none"
              style={[styles.fade, styles.fadeTop]}
              colors={TOP_FADE}
              locations={FADE_LOCATIONS as any}
            />
            <ArcList.Root
              height={CARD_HEIGHT}
              itemHeight={ITEM_HEIGHT}
              minOpacity={custom.minOpacity}
              minScale={custom.minScale}
              side={custom.side as "left" | "right"}
              radius={custom.radius}
              defaultIndex={8}
            >
              <ArcList.Viewport>{rows}</ArcList.Viewport>
            </ArcList.Root>
            <LinearGradient
              pointerEvents="none"
              style={[styles.fade, styles.fadeBottom]}
              colors={BOTTOM_FADE}
              locations={FADE_LOCATIONS as any}
            />
          </View>
        </View>
      </SafeAreaView>
    </Showcase>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1 },
  container: { flex: 1, backgroundColor: "#0a0a0a" },
  content: { padding: 16, gap: 4 },
  title: { color: "#fff", fontSize: 22, fontWeight: "600" },
  subtitle: { color: "#6b6b6b", fontSize: 13 },
  stage: { flex: 1, alignItems: "center", justifyContent: "center" },
  card: {
    width: _width,
    height: CARD_HEIGHT,
    paddingLeft: 18,
    borderRadius: 28,
    backgroundColor: CARD_BG,
    overflow: "hidden",
  },
  fade: {
    position: "absolute",
    left: 0,
    right: 0,
    height: FADE_HEIGHT,
    zIndex: 1,
  },
  fadeTop: { top: 0 },
  fadeBottom: { bottom: 0 },
  plus: { fontSize: 12, lineHeight: 14, fontWeight: "700" },
  label: { fontSize: 19, fontFamily: "SfProRounded" },
  caption: {
    paddingBottom: 24,
    textAlign: "center",
    fontSize: 13,
    letterSpacing: 2,
    textTransform: "uppercase",
    color: "#8a8a8a",
  },
});

Пропсы

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

Тип ReactNode

itemHeight?

Тип number

height?

Тип number

sweep?

Тип number

radius?

Тип number

side?

Тип ArcSide

snap?

Тип boolean

index?

Тип number

defaultIndex?

Тип number

minOpacity?

Тип number

minScale?

Тип number

haptics?

Тип boolean

onIndexChange?

Тип (index: number) => void

style?

Тип StyleProp<ViewStyle>