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.
.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 withtransform: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;imgobject-fit:cover; border-radius:7px..tr-center— absolutely centred column,gap:.6rem; z-index:100; pointer-events:none; mark is a 10px dot with a0 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.
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
100svhand the ring becomes an accelerator only while the pointer is over it. - Items are positioned with inline
transform, so GSAP only ever touchesscaleandfilteron 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:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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.