# Tilted Ring Gallery — Wheel-Accelerated Oval Orbit with Hover Spotlight

## Goal
Build a fullscreen dark gallery where **twelve glass-framed photos orbit on a tilted oval**. The ring **turns by itself** at a slow idle speed; every **wheel tick adds speed** (capped) and sets the direction, and the speed **eases back** to idle. Because the items move under a still cursor, the hovered item is found with `elementFromPoint` on every frame: the hovered photo **scales up** (its inner image relaxes from a 1.1 idle zoom to 1) while every other item **desaturates, darkens and blurs**. A small dot and "Scroll to spin" sit at the centre. rAF loop + GSAP tweens.

## Tech
Vanilla HTML/CSS/JS with ES module imports. `gsap` (npm), no plugins. Font: **Space Mono** for the centre label.

## Layout / HTML
```html
<div class="tr">
  <section class="tr-gallery" aria-label="Gallery">
    <div class="tr-center" aria-hidden="true">
      <span class="tr-center__mark"></span>
      <span class="tr-center__label">Scroll to spin</span>
    </div>
  </section>
</div>
```
JS appends twelve `<div class="tr-item"><div class="tr-frame"><img src="img{n}.jpg"></div></div>`.

## Styling
`:root`: `--tr-bg:#0c0d10`, `--tr-ink:#ecebe6`, `--tr-line:rgba(255,255,255,.22)`, `--tr-item-w:240px`, `--tr-item-h:160px`.

- `.tr` — `min-height:100svh; background:var(--tr-bg); color:var(--tr-ink); font-family:"Space Mono"`.
- `.tr-gallery` — `position:relative; width:100%; height:100svh; overflow:hidden; background: radial-gradient(80vmax 50vmax at 50% 110%, rgba(255,255,255,.08), transparent 60%), radial-gradient(120vmax 120vmax at 50% 50%, transparent 40%, rgba(0,0,0,.55) 100%)` (a low light and a vignette).
- `.tr-item` — `position:absolute; top:50%; left:50%; width:var(--tr-item-w); height:var(--tr-item-h); margin-left:calc(var(--tr-item-w)/−2); margin-top:calc(var(--tr-item-h)/−2)` (centred, then moved with `transform:translate()` by JS).
- `.tr-frame` — the glass: `position:relative; width:100%; height:100%; padding:4px; border-radius:10px; background:rgba(255,255,255,.08); border:1px solid var(--tr-line); box-shadow:0 40px 60px −30px rgba(0,0,0,.8), inset 0 1px 0 rgba(255,255,255,.25); overflow:hidden`; `img` `object-fit:cover; border-radius:7px`.
- `.tr-center` — absolutely centred column, `gap:.6rem; z-index:100; pointer-events:none`; mark is a 10px dot with a `0 0 0 6px rgba(255,255,255,.08)` halo; label `.65rem`, `letter-spacing:.18em`, uppercase, `opacity:.55`.
- `@media (max-width:1000px)`: `--tr-item-w:150px; --tr-item-h:100px`.

## Effect (be exact)
Build in `mount(config)` → `destroy()`.

```js
const DEFAULTS = { count: 12, ovalWidthRatio: .28, ovalHeightRatio: .26, tiltAngle: -20, idleSpeed: .035, scrollAcceleration: .0055, maxSpeed: 2.5, speedEasing: .05, hoverScale: 1.1, idleImageZoom: 1.1, dimmedBrightness: .35, dimmedBlur: 2, hoverDuration: 1 };
```

**Build** — for `i < count`: create item/frame/img, `gsap.set(frame, { scale: 1 })`, `gsap.set(img, { scale: idleImageZoom })`, `gsap.set(item, { filter: "saturate(1) brightness(1) blur(0px)" })`, store `angle = (i / count) * 2π`.

**Measure** (at mount and on resize) — `rx = gallery.clientWidth * ovalWidthRatio`, `ry = gallery.clientHeight * ovalHeightRatio`, `tiltCos/tiltSin` from `tiltAngle` in radians.

**Place** — `a = item.angle + rotation`; `ox = cos(a) * rx`, `oy = sin(a) * ry`; rotate by the tilt: `x = ox*tiltCos − oy*tiltSin`, `y = ox*tiltSin + oy*tiltCos`; `item.style.transform = translate(x px, y px)`; `zIndex = round(50 + sin(a) * 40)` so the front of the oval overlaps the back.

**Wheel** (on the gallery, `passive:true`) — `direction = deltaY > 0 ? 1 : −1`; `speed = min(speed + |deltaY| * scrollAcceleration, maxSpeed)`.

**Loop** (rAF) — `speed += (idleSpeed − speed) * speedEasing`; `rotation += speed * direction` (degrees); `rad = rotation * π/180`; find the hovered item (`elementFromPoint(px, py)?.closest(".tr-item")`, pointer coordinates kept from `pointermove` on window, reset to −1 on `pointerleave`); if it changed, `applyHover(item)`; place every item; loop.

**applyHover(active)** — for each item, `isActive = item === active`, `dimmed = active && !isActive`, common options `{ duration: hoverDuration, ease: "expo.out", overwrite: true }`: frame `scale: isActive ? hoverScale : 1`; image `scale: isActive ? 1 : idleImageZoom`; item `filter: saturate(${dimmed ? 0 : 1}) brightness(${dimmed ? dimmedBrightness : 1}) blur(${dimmed ? dimmedBlur : 0}px)`.

**destroy()** — cancel the rAF, remove listeners, kill tweens, remove the items.

## Assets / images
Twelve landscape photos (≈3:2, ~600px) in three coherent groups of four: landscapes, architecture, skies. Files `img1.jpg … img12.jpg`.

## Behavior notes
- The wheel listener is passive and attached to the section, so the page is never blocked; in a longer page this section should be `100svh` and the ring becomes an accelerator only while the pointer is over it.
- Items are positioned with inline `transform`, so GSAP only ever touches `scale` and `filter` on the inner elements — no conflict.
- Touch devices: the ring idles; there is no wheel, and hover doesn't apply.
- Only the prefixed variables are global.

## Images

This component ships with 12 reference assets, served publicly.
Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the
same aspect ratios.

```
https://motionprompts.dev/c/tilted-ring-gallery/img1.jpg
https://motionprompts.dev/c/tilted-ring-gallery/img10.jpg
https://motionprompts.dev/c/tilted-ring-gallery/img11.jpg
https://motionprompts.dev/c/tilted-ring-gallery/img12.jpg
https://motionprompts.dev/c/tilted-ring-gallery/img2.jpg
https://motionprompts.dev/c/tilted-ring-gallery/img3.jpg
… 6 more under https://motionprompts.dev/c/tilted-ring-gallery/
```

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for
demonstration of this component, not for redistribution.

## Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

- **Palette on `:root`** — `--tr-bg`, `--tr-ink`, `--tr-line`, `--tr-item-w`, `--tr-item-h`. These names are not namespaced and they collide: `--ink` is defined by 164 of the 219 components in this catalogue, `--paper` by 94, `--muted` by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (`.my-section { --ink: … }`) or rename them with a prefix.
- **Rules on `html, body`** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
