All components

Tilted Ring Gallery

GSAP animation component · Published 2026-09-05 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Twelve glass-framed photos orbit on a tilted oval: a rAF loop advances the ring at an idle speed, each wheel event adds to the speed (capped) and sets the direction, and the speed eases back to idle. The photo under the pointer, found with elementFromPoint because the items move under a still cursor, scales up while the others desaturate, darken and blur.

How it's built

Categorygallery
Techgsap
Complexitysection
Performance costlight
Mobile-safeyes

gallery ring orbit wheel raf hover blur glass

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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

<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.

  • .trmin-height:100svh; background:var(--tr-bg); color:var(--tr-ink); font-family:"Space Mono".
  • .tr-galleryposition: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-itemposition: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().

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.

Placea = 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.