Motiva
Мои коллекции
← каталог

Photo Ring

Кольцо снимков, которое вращает прокрутка: сцена липнет к экрану, кадры идут по кругу, и каждый держится вертикально контрповоротом — как кабинки колеса обозрения. Ноль скриптов: проход считает внешняя коробка и публикует одно число через @property, рычаги и контрповороты его читают (view() на слоях липкой сцены не работает — они через экран не проходят).

cssscrolladvancedreduced-motion ✓
↗
↕ демо длиннее кадра — прокрутите его

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
turns?number0.6Сколько оборотов кольцо делает за проход сцены.
length?number2.6Высота запаса прокрутки, экраны.
children?ReactNode—Подпись в центре кольца (обычно заголовок).

Установка

npx shadcn@latest add https://motiva.pages.dev/r/photo-ring.json
Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useId } from "react";
import type { CSSProperties, ReactNode } from "react";

export interface PhotoRingProps {
  /** Снимки кольца; 6–14. */
  items?: string[];
  /** Сколько оборотов кольцо делает за проход сцены. */
  turns?: number;
  /** Высота запаса прокрутки, экраны. */
  length?: number;
  /** Подпись в центре кольца (обычно заголовок). */
  children?: ReactNode;
}

const ITEMS = [
  "https://motiva.pages.dev/img/1015-600x750.jpg",
  "https://motiva.pages.dev/img/1016-600x750.jpg",
  "https://motiva.pages.dev/img/1018-600x600.jpg",
  "https://motiva.pages.dev/img/1019-600x750.jpg",
  "https://motiva.pages.dev/img/1021-600x750.jpg",
  "https://motiva.pages.dev/img/1022-600x750.jpg",
  "https://motiva.pages.dev/img/1024-600x750.jpg",
  "https://motiva.pages.dev/img/1025-600x750.jpg",
  "https://motiva.pages.dev/img/1027-400x400.jpg",
  "https://motiva.pages.dev/img/1029-600x750.jpg",
];

/**
 * Кольцо снимков, которое вращает прокрутка: сцена липнет к экрану, кадры идут
 * по кругу, и каждый снимок КОНТРПОВОРОТОМ держится вертикально — как кабинки
 * колеса обозрения. Ноль скриптов.
 *
 * Схема — та же, что у svg-parallax-scene: `view()` нельзя вешать на слои липкой
 * сцены (они через экран не проходят), поэтому проход считает ВНЕШНЯЯ коробка
 * и публикует одно число через `@property` (`--prg-p`), а рычаг каждого снимка
 * и его контрповорот это число читают. Без `@property` переменная — строка,
 * и браузер не стал бы её интерполировать.
 *
 * База (нет поддержки, `prefers-reduced-motion`, `data-shot`) — неподвижное
 * кольцо: композиция читается и без движения.
 */
export function PhotoRing({ items = ITEMS, turns = 0.6, length = 2.6, children }: PhotoRingProps = {}) {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const n = items.length;
  return (
    <div className="prg" data-prg={uid} style={{ ["--prg-length" as string]: length }}>
      <style>{`
        @property --prg-p { syntax: "<number>"; inherits: true; initial-value: 0; }

        [data-prg="${uid}"] {
          --prg-p: 0;
          position: relative;
          width: 100%;
          height: calc(var(--prg-length, 2.6) * 100svh);
        }
        [data-prg="${uid}"] .prg-stage {
          position: sticky; top: 0;
          height: 100svh;
          display: grid; place-items: center;
          overflow: clip;
        }
        [data-prg="${uid}"] .prg-hub {
          position: relative;
          width: 0; height: 0;
        }
        [data-prg="${uid}"] .prg-arm {
          position: absolute; left: 0; top: 0;
          rotate: calc(var(--prg-k) * ${(360 / n).toFixed(3)}deg + var(--prg-p) * ${turns} * 360deg);
        }
        [data-prg="${uid}"] .prg-card {
          position: absolute;
          /* рычаг имеет нулевую ширину, а глобальный reset ставит картинкам
             max-width: 100% — без отмены от снимка остаются одни рамки */
          max-width: none;
          width: clamp(84px, 13svh, 128px);
          aspect-ratio: 3 / 4;
          translate: -50% calc(min(34svh, 38cqw) * -1 - 50%);
          border-radius: 10px;
          object-fit: cover;
          border: 3px solid var(--ds-surface, oklch(0.95 0.01 265));
          box-shadow: 0 8px 22px oklch(0 0 0 / 0.3);
          /* контрповорот: кабинка колеса держится вертикально при любом угле рычага */
          rotate: calc(var(--prg-k) * ${(-360 / n).toFixed(3)}deg - var(--prg-p) * ${turns} * 360deg);
        }
        [data-prg="${uid}"] .prg-center {
          position: absolute; left: 50%; top: 50%;
          translate: -50% -50%;
          text-align: center; pointer-events: none;
        }

        @keyframes prg-run-${uid} { to { --prg-p: 1; } }
        @supports (animation-timeline: view()) {
          @media (prefers-reduced-motion: no-preference) {
            [data-prg="${uid}"] {
              animation: prg-run-${uid} linear both;
              animation-timeline: view();
              animation-range: contain;
            }
          }
        }
        html[data-shot] [data-prg="${uid}"] { animation: none; }
      `}</style>

      <div className="prg-stage">
        <div className="prg-hub" aria-hidden="true">
          {items.map((src, k) => (
            <div className="prg-arm" key={src} style={{ "--prg-k": k } as CSSProperties}>
              <img className="prg-card" src={src} alt="" loading="lazy" draggable={false} />
            </div>
          ))}
        </div>
        {children && <div className="prg-center">{children}</div>}
      </div>
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — madewithgsap.com/effects (позиция 100, превью сетки; платная платформа, исходники не открывались)v1.0.0