Photo Ring
Кольцо снимков, которое вращает прокрутка: сцена липнет к экрану, кадры идут по кругу, и каждый держится вертикально контрповоротом — как кабинки колеса обозрения. Ноль скриптов: проход считает внешняя коробка и публикует одно число через @property, рычаги и контрповороты его читают (view() на слоях липкой сцены не работает — они через экран не проходят).
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| turns? | number | 0.6 | Сколько оборотов кольцо делает за проход сцены. |
| length? | number | 2.6 | Высота запаса прокрутки, экраны. |
| children? | ReactNode | — | Подпись в центре кольца (обычно заголовок). |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/photo-ring.jsonЧто нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}