OLVR sells lab-grown diamond engagement rings. The brief from the client was a single screenshot of Rolex's website: a watch that rotates smoothly as you scroll, locking your attention to a single object as you read about it. The ask was simple — we want that, for our rings.

The shipped effect — scroll-bound rotation across 360 frames, served as WebP, preloaded eagerly. The version on roughworks.ca uses the same approach.

Rolex's implementation is a custom WebGL 3D model in their proprietary browser-based viewer. That's appropriate for a luxury brand with a dedicated 3D engineering budget. OLVR is a Shopify store that competes on conversion, and conversion lives or dies by Lighthouse. We needed the same visual outcome with a tenth of the engineering cost, a tenth of the asset complexity, and no 3D-engine maintenance burden on the client's team going forward.

What we shipped looks like Rolex, performs better than Rolex, and any Shopify developer can maintain it. Below is every piece of the engineering — what we ruled out, what we shipped, the code, the optimization stack, and the numbers.

Why we ruled out WebGL up front

For a luxury brand with a 3D engineering team, WebGL is the right answer. For everyone else in 2026, WebGL on a product page costs more than it returns. The four reasons we ruled it out for OLVR:

1. Lighthouse Performance penalty. A real WebGL ring model with detailed materials, normal maps, environment reflections, and good lighting drops Performance scores by 10-20 points on mid-tier mobile devices. OLVR's conversion rate is directly correlated with page speed. We can't trade a measurable conversion lift for a visual improvement that 80% of visitors won't consciously notice.

2. The 3D asset pipeline is a recurring cost. Adding a new product means modeling the ring in Blender, configuring materials, baking lighting, exporting to glTF, validating in the viewer, integrating into Shopify's product schema. None of OLVR's team are 3D artists. They'd have to either hire one or pay an agency every time they launched a new product.

3. WebGL on mobile is uneven. iOS Safari's WebGL implementation has historically been the most fragile target on the web. Frame-rate stutters, occasional driver bugs, battery drain warnings. For an e-commerce site where half the traffic is mobile, these are real problems.

4. Real-time 3D solves a problem we didn't have. Rolex's viewer lets you click-and-drag to rotate the watch freely. That's a great feature when the user wants to dissect a $40,000 watch from every angle. For a category browse page, where the goal is "show this product compellingly while the visitor reads about it," forced scroll-bound rotation is better than free rotation — it's a directed experience instead of a fiddly one.

The pre-rendered alternative

The shape of the solution: pre-render the ring at every degree of rotation, ship the frames as a sequence, swap the displayed frame as the user scrolls. No real-time 3D, no WebGL, no engine.

For each ring product, our design team produces 360 frames at 1° increments — a full revolution. Each frame is exported from Blender / Cinema 4D at the design team's preferred render settings (which now matches OLVR's existing product photography pipeline), then compressed to WebP.

The numbers that make this practical:

  • WebP lossy compression at quality 85 gets each frame to about 12-18 KB for a 1200×1200 image
  • 360 frames × 15 KB average = ~5.4 MB total per ring
  • A single high-resolution hero image at the same dimensions clocks in around 800 KB to 1.5 MB

We pay roughly 4-7× the bytes of a single hero image to get an interactive, continuous rotation. That's a much better trade than a 3-5 MB glTF model that runs a WebGL pipeline on every frame.

The scroll-to-frame mechanism

The display element is a single <img> tag whose src swaps as the user scrolls. The mechanism is simpler than people expect:

const TOTAL_FRAMES = 360;
const ringElement = document.querySelector('.ring-display');
const section = document.querySelector('.ring-scroll-section');

let currentFrame = -1;
let ticking = false;

function updateFrame() {
  const rect = section.getBoundingClientRect();
  const sectionScrollableHeight = rect.height - window.innerHeight;
  const progress = Math.max(0, Math.min(1, -rect.top / sectionScrollableHeight));
  const frame = Math.floor(progress * (TOTAL_FRAMES - 1));

  if (frame !== currentFrame) {
    currentFrame = frame;
    ringElement.src = `${baseUrl}/ring-${String(frame).padStart(3, '0')}.webp`;
  }
  ticking = false;
}

window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(updateFrame);
    ticking = true;
  }
}, { passive: true });

Three things to notice in the above:

The passive: true listener flag. This tells the browser the handler will never call preventDefault(), which lets scroll continue at native speed without waiting for our JavaScript to finish. On Safari mobile this is the difference between a buttery scroll and a janky one.

The requestAnimationFrame throttle pattern. Scroll events fire at the input device's rate (which can be 120 Hz on ProMotion, 240 Hz on some gaming monitors). We don't want to swap the src 240 times a second. The ticking boolean ensures we update at most once per browser repaint cycle.

The currentFrame !== frame check. When the user scrolls within a single frame's range (say, the section is 7,200 px tall and each frame covers 20 px of scroll), we skip the DOM write entirely. This is where most of the runtime savings come from.

The preload pattern that makes it actually smooth

The naive scroll-to-frame approach has one fatal problem: the first time the user scrolls past a frame, the browser has to fetch the image. That fetch takes 50-200 ms over a normal connection, and during that time the displayed frame is stale. Scroll feels stuttery and slow.

The fix is to preload every frame into memory as soon as the section enters the viewport with a generous root margin:

const preloadFrames = () => {
  const promises = [];
  for (let i = 0; i < TOTAL_FRAMES; i++) {
    const img = new Image();
    const url = `${baseUrl}/ring-${String(i).padStart(3, '0')}.webp`;
    promises.push(new Promise(resolve => {
      img.onload = resolve;
      img.onerror = resolve;
      img.src = url;
    }));
  }
  return Promise.all(promises);
};

const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      preloadFrames();
      observer.disconnect();
      break;
    }
  }
}, { rootMargin: '600px' });

observer.observe(section);

The rootMargin: '600px' is the load-bearing detail. It triggers preloading 600 pixels before the section enters the viewport, giving the network 600 / scrollSpeed milliseconds of head start. On a normal connection, the entire 5.4 MB sequence is in memory by the time the user starts scrolling through the section.

Once preloaded, <img>.src swaps are instant. The browser already has the bitmap decoded; the swap is a single DOM mutation with no network round trip.

The HTTP/2 connection multiplexing detail

A subtle thing happens when you fire 360 image requests in a tight loop: HTTP/1.1 browsers can only have 6 connections per origin, so the requests get queued. The first 6 download fast, the rest queue, and the user's perceived load time is the wall-clock of all 360 frames.

HTTP/2 fixes this — a single TCP connection multiplexes all 360 requests, and the browser pipelines them efficiently. Every major CDN supports HTTP/2 by default in 2026.

OLVR is on Shopify's CDN, which serves the ring frames over HTTP/2. We measured the cold-load time of the full 360-frame sequence on a typical 4G mobile connection at 2.1 seconds for 5.4 MB — about as fast as a single 5 MB hero image would load over the same connection.

The <picture> fallback for legacy browsers

WebP is ubiquitously supported in 2026, but we still build defensively. The display element is wrapped in a <picture> with a JPEG fallback for the initial frame:

<section class="ring-scroll-section">
  <div class="ring-stage">
    <picture>
      <source type="image/webp" srcset="/cdn/ring-001/ring-000.webp">
      <img class="ring-display"
           src="/cdn/ring-001/ring-000.jpg"
           alt="OLVR engagement ring"
           width="1200" height="1200"
           loading="eager"
           fetchpriority="high">
    </picture>
  </div>
  <div class="ring-narrative"><!-- text content scrolls over the ring --></div>
</section>

The fetchpriority="high" on the initial frame is important — without it, the browser may deprioritize the ring image behind less-important assets that appear higher in the document order. With it, the ring is among the first things to paint.

The CSS that locks the ring in place

The ring is position: sticky inside a tall outer section. The user scrolls; the ring stays centered. The page-internal text scrolls past it.

.ring-scroll-section {
  position: relative;
  height: 800vh; /* 8 viewports tall — gives 360 frames room to play out */
}

.ring-stage {
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.ring-display {
  max-width: min(60vw, 700px);
  height: auto;
  will-change: contents; /* hint to the browser that src changes are expected */
}

.ring-narrative {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* narrative blocks are absolutely positioned at known scroll percentages */
}

The height: 800vh is what controls the scroll speed. Tall outer container = slow rotation; short outer container = fast rotation. We tuned 800vh by user testing — slow enough that the rotation is meditative, fast enough that an impatient scroller still sees the whole ring.

The will-change: contents is a real performance improvement. It tells the browser that the <img> is going to update its content, so it should promote the element to its own compositor layer. The src swap then doesn't trigger a layout or repaint of surrounding content.

The fallback for non-scrollers

Some users land on the page from a deep link, see the ring, and never scroll. Some users use assistive technology that doesn't move the viewport. For both, we want the ring to be visible without scroll input.

After 3 seconds of pageview with no scroll detected, we start an auto-rotation:

let lastScrollTime = Date.now();
let autoRotateRAF = null;

window.addEventListener('scroll', () => {
  lastScrollTime = Date.now();
  if (autoRotateRAF) {
    cancelAnimationFrame(autoRotateRAF);
    autoRotateRAF = null;
  }
}, { passive: true });

function maybeAutoRotate() {
  const idleMs = Date.now() - lastScrollTime;
  if (idleMs > 3000) {
    const t = (Date.now() / 10000) % 1; // one rotation per 10s
    const frame = Math.floor(t * (TOTAL_FRAMES - 1));
    if (frame !== currentFrame) {
      currentFrame = frame;
      ringElement.src = `${baseUrl}/ring-${String(frame).padStart(3, '0')}.webp`;
    }
  }
  autoRotateRAF = requestAnimationFrame(maybeAutoRotate);
}

setTimeout(() => { autoRotateRAF = requestAnimationFrame(maybeAutoRotate); }, 3000);

The auto-rotation feels intentional rather than disruptive because it's slow (10s per revolution). When the user scrolls, we hand control back instantly.

What this actually buys you (measured)

We measured the production OLVR ring-scroll section against the WebGL prototype we built first:

Metric WebGL prototype Sprite implementation
Lighthouse Performance (mobile)7194
Total Blocking Time480ms90ms
Largest Contentful Paint3.2s1.4s
Memory footprint after scroll-through~85 MB~22 MB
Time to interactive4.1s1.9s
Conversion rate on the page (A/B)baseline+3.2%

The 3.2% lift on add-to-cart isn't directly attributable to "the ring loads faster" — but it's the kind of thing that compounds. On a Shopify store doing meaningful volume, that's real money.

When you'd actually still pick WebGL

We're not anti-WebGL. We use it when the requirements call for it:

  • The user genuinely needs to manipulate the 3D object — click-and-drag rotation, lighting adjustment, zoom-to-detail. For a $40K Rolex, this is appropriate. For a $4K OLVR ring on a category page, it isn't.
  • The object's appearance is parameter-driven — material customization (gold tone, gem setting, band style) where each combination would require its own pre-rendered sequence. At some point you have so many sequences that real-time rendering is cheaper.
  • The object is composed of many variant parts — engagement ring builders where each component (band, head, stones) is independently selectable. We use WebGL for OLVR's actual ring-builder configurator on the product detail page; we use sprite sequences for the marketing showcases.

The decision rule is straightforward: sprites for cinematic showcases, WebGL for interactive customization. Most projects need one of those, not both — and "showcase" is by far the more common requirement.

The agency takeaway

The reason this works as a portfolio piece isn't the technical specifics — it's the meta-lesson: the best agency engineering choice is usually the one with the lowest ongoing maintenance cost that still meets the brief. Pre-rendered frames work because OLVR's team can produce them with the same Blender skills they already use for product photography. Webflow + GSAP works for Farm Minerals because their team needs to update copy weekly. Static HTML works for Rough Works because we update our own site.

If your agency's first answer to every problem is "let's build something custom in WebGL/Next.js/React Three Fiber," they're optimizing for their own portfolio, not your maintenance bill. Push back. Ask what the cheapest, simplest tool is that still hits the goal.

For OLVR, the cheapest simplest tool was a <img> tag with 360 frames. The Rolex effect, at Shopify-level engineering cost, with better Lighthouse scores than the WebGL version. That's the trade we always want to make.

If you're building a product page that needs an effect like this — we'd love to talk about it. We've shipped enough variants now (rings, watches, perfume bottles, custom shoes) that we have a template ready.