Building a Sticky Product Story Without WebGL #
One interaction pattern keeps appearing in high-end product websites: the product stops behaving like an illustration attached to a section and becomes the visual anchor of the entire story.
That distinction is more important than it sounds.
A conventional landing page often works like this:
IMAGE
text
IMAGE
text
IMAGE
text
Each section introduces another image and another argument.
The user keeps starting over visually.
A product-led story can instead work like this:
PRODUCT
↓
Design PRODUCT
Material PRODUCT
Details PRODUCT
Power PRODUCT
CTA PRODUCT
The object stays.
The narrative moves around it.
That was the idea I wanted to explore after looking through the latest award feeds from August 15–17, 2026.
The August 15–17 Design Feed #
The strongest reference for me was The Watch, which Awwwards named Site of the Day on August 17.
Awwwards describes the project as a real-time WebGL presentation of the FS 60P watch that interacts with the content around it. The technology listing explicitly includes 3D, WebGL and Three.js.
Original project / award page:
https://www.awwwards.com/sites/the-watch
That implementation is interesting because the 3D product is not isolated inside a decorative hero.
It participates in the page.
As the content changes, the object remains the visual reference point.
That is the part I wanted.
Not necessarily the renderer.
CSS Design Awards also listed Space Capital on August 17, tagged with animated, grid and WebGL.
https://www.cssdesignawards.com/sites/space-capital/49996/
Another August 17 nominee, Graffico Office, takes the immersive route even further: CSS Design Awards describes it as a 3D studio visitors can enter and explore, with animated, video/sound and WebGL tags.
https://www.cssdesignawards.com/sites/graffico-office/49998/
At the opposite end of the spectrum, CSS Winner selected HAOQI.DESIGN as its August 17 Site of the Day. Its presence in the same feed is a useful reminder that not every contemporary portfolio needs to become a graphics-engine demo.
That contrast is exactly what makes this interesting.
The choice is not:
boring website
vs
WebGL website
There is a very large middle ground.
The Watch: The Product Stays, the Story Moves #
What I wanted to borrow from The Watch was this relationship:
the content does not repeatedly introduce a different object; the content changes the state of one persistent object.
For a watch, that can mean actual real-time 3D.
For an ordinary client project, it could mean much less:
position
rotation
scale
lighting
caption
image
Those properties are enough to make the same object feel different as the user moves through the story.
So I built a Sticky Product Story.
No Three.js.
No GSAP.
No WebGL.
The left side holds the visual.
The right side contains full-height narrative steps.
As the reader moves between those steps, the product changes state.
CodePen Demo #
I would place the live Pen here, before the implementation.
I prefer embedding the complete implementation rather than rebuilding a smaller “article demo.” The reader should be able to switch from Result to HTML, CSS and JavaScript and inspect the same component discussed below.
HTML #
The structure contains one sticky visual stage, four narrative steps and a compact navigation control.
<section class="product-story" data-product-story>
<div class="product-story__visual">
<div class="product-stage" data-product-stage>
<span class="product-stage__light" aria-hidden="true"></span>
<div class="product-stage__object">
<img
src="https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/048b4913-5180-4a01-8b68-0b9f150c7dfb/d5f5xnq-e7ebf6f3-aa72-4fb4-8aa7-1baf02aace19.png?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1cm46YXBwOjdlMGQxODg5ODIyNjQzNzNhNWYwZDQxNWVhMGQyNmUwIiwiaXNzIjoidXJuOmFwcDo3ZTBkMTg4OTgyMjY0MzczYTVmMGQ0MTVlYTBkMjZlMCIsIm9iaiI6W1t7InBhdGgiOiIvZi8wNDhiNDkxMy01MTgwLTRhMDEtOGI2OC0wYjlmMTUwYzdkZmIvZDVmNXhucS1lN2ViZjZmMy1hYTcyLTRmYjQtOGFhNy0xYmFmMDJhYWNlMTkucG5nIn1dXSwiYXVkIjpbInVybjpzZXJ2aWNlOmZpbGUuZG93bmxvYWQiXX0.j7joMDQtFEIMkPjLJOXpUvRF9tB7jtQumEshCES2Qao"
alt="Premium product"
class="product-stage__image"
width="900"
height="1100"
loading="eager"
decoding="async"
>
</div>
<div
class="product-stage__meta"
aria-live="polite"
aria-atomic="true"
>
<span data-product-number>01</span>
<span data-product-label>Silhouette</span>
</div>
</div>
<nav
class="product-story__nav"
aria-label="Product features"
role="tablist"
>
<button
type="button"
role="tab"
class="is-active"
data-story-nav="0"
aria-label="Silhouette"
aria-controls="step-0"
aria-selected="true"
></button>
<button
type="button"
role="tab"
data-story-nav="1"
aria-label="Cabin"
aria-controls="step-1"
aria-selected="false"
></button>
<button
type="button"
role="tab"
data-story-nav="2"
aria-label="Details"
aria-controls="step-2"
aria-selected="false"
></button>
<button
type="button"
role="tab"
data-story-nav="3"
aria-label="Power"
aria-controls="step-3"
aria-selected="false"
></button>
</nav>
</div>
<div class="product-story__content">
<article
class="story-step"
data-story-step
data-label="Silhouette"
id="step-0"
role="region"
aria-labelledby="step-heading-0"
data-x="-2"
data-y="0"
data-rotate="-4"
data-scale="1"
data-light="35"
>
<span class="story-step__number">01</span>
<h2 id="step-heading-0">
Shaped by air,
<strong>not habit.</strong>
</h2>
<p>
Every panel earns its curve. The silhouette was refined in the tunnel
first and the studio second, so nothing on the surface is there
just to look fast.
</p>
</article>
<article
class="story-step"
data-story-step
data-label="Cabin"
id="step-1"
role="region"
aria-labelledby="step-heading-1"
data-x="4"
data-y="-2"
data-rotate="5"
data-scale="1.05"
data-light="66"
>
<span class="story-step__number">02</span>
<h2 id="step-heading-1">
A cabin built
<strong>around the driver.</strong>
</h2>
<p>
Stitched alcantara, a low seating line, and controls placed exactly
where a hand rests naturally — nothing borrowed from a parts bin.
</p>
</article>
<article
class="story-step"
data-story-step
data-label="Details"
id="step-2"
role="region"
aria-labelledby="step-heading-2"
data-x="-5"
data-y="3"
data-rotate="-7"
data-scale="1.14"
data-light="80"
>
<span class="story-step__number">03</span>
<h2 id="step-heading-2">
The signature light,
<strong>drawn once.</strong>
</h2>
<p>
A single continuous strip replaces four separate lamps. It's the
one detail people recognize from three blocks away, day or night.
</p>
</article>
<article
class="story-step"
data-story-step
data-label="Power"
id="step-3"
role="region"
aria-labelledby="step-heading-3"
data-x="2"
data-y="-2"
data-rotate="2"
data-scale=".95"
data-light="50"
>
<span class="story-step__number">04</span>
<h2 id="step-heading-3">
Quiet at idle.
<strong>Immediate at full throttle.</strong>
</h2>
<p>
Dual motors, a low center of gravity, and a chassis tuned for one
job: making the power feel like an extension of the driver's foot.
</p>
<a href="#" class="story-step__cta">
View full specs
<span aria-hidden="true">→</span>
</a>
</article>
</div>
</section>
CSS #
The key primitive here is position: sticky.
A sticky element participates in normal layout until its scroll container reaches the specified threshold; after that it remains constrained to that position until it reaches the opposite edge of its containing block. That makes it a very natural foundation for a product that should remain visible while adjacent content keeps moving.
:root {
--story-bg: #0a0d12;
--story-text: #f2f4f7;
--story-muted: #7a8290;
--story-line: rgba(255, 255, 255, .1);
--story-accent: #327294;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--story-bg);
font-family: "Helvetica Neue", Arial, sans-serif;
}
.product-story {
position: relative;
display: grid;
grid-template-columns: minmax(420px, 1fr) minmax(420px, 1fr);
background: var(--story-bg);
color: var(--story-text);
}
/* Sticky visual */
.product-story__visual {
position: sticky;
inset-block-start: 0;
height: 100vh;
display: grid;
place-items: center;
overflow: hidden;
}
.product-stage {
--product-x: -2;
--product-y: 0;
--product-rotate: -4;
--product-scale: 1;
--product-light: 35;
position: relative;
width: min(80%, 640px);
height: min(70vh, 560px);
display: grid;
place-items: center;
}
/* Ambient light — doubles as the cars headlight glow */
.product-stage__light {
position: absolute;
width: 78%;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(
circle,
rgba(94, 231, 255, calc(var(--product-light) / 260)),
rgba(94, 231, 255, 0) 68%
);
filter: blur(30px);
transform:
translate(
calc(var(--product-x) * 4px),
calc(var(--product-y) * 4px)
)
scale(calc(.85 + var(--product-light) / 200));
transition:
transform 700ms cubic-bezier(.2, .8, .2, 1),
background 700ms ease;
}
/* Product */
.product-stage__object {
position: relative;
width: 92%;
max-height: 80%;
transform:
translate3d(
calc(var(--product-x) * 1px),
calc(var(--product-y) * 1px),
0
)
rotate(calc(var(--product-rotate) * 1deg))
scale(var(--product-scale));
transform-origin: center;
transition: transform 700ms cubic-bezier(.18, .85, .22, 1);
will-change: transform;
}
.product-stage__image {
display: block;
width: 100%;
height: auto;
max-height: 66vh;
object-fit: contain;
filter: drop-shadow(0 35px 45px rgba(0, 0, 0, .5));
}
/* Product metadata */
.product-stage__meta {
position: absolute;
inset-inline-end: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 16px;
font-family: monospace;
font-size: 11px;
color: var(--story-muted);
}
.product-stage__meta span:first-child {
color: var(--story-accent);
}
/* Navigation */
.product-story__nav {
position: absolute;
inset-inline-start: clamp(20px, 4vw, 60px);
top: 50%;
display: grid;
gap: 10px;
transform: translateY(-50%);
}
.product-story__nav button {
width: 7px;
height: 7px;
padding: 0;
border: 0;
border-radius: 50%;
background: #3a4048;
cursor: pointer;
transition:
height 250ms ease,
background 250ms ease,
border-radius 250ms ease;
}
.product-story__nav button:hover {
background: #6b7280;
}
.product-story__nav button.is-active {
height: 36px;
border-radius: 20px;
background: var(--story-accent);
}
/* Content */
.product-story__content {
position: relative;
border-inline-end: 1px solid var(--story-line);
}
.story-step {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(60px, 8vw, 130px) clamp(40px, 7vw, 110px);
border-block-end: 1px solid var(--story-line);
opacity: .38;
transition: opacity 350ms ease;
}
.story-step.is-active {
opacity: 1;
}
.story-step__number {
margin-block-end: 36px;
font-family: monospace;
font-size: 11px;
letter-spacing: .08em;
color: var(--story-accent);
}
.story-step h2 {
max-width: 620px;
margin: 0;
font-size: clamp(48px, 6vw, 92px);
line-height: .95;
letter-spacing: -.045em;
font-weight: 500;
}
.story-step h2 strong {
display: block;
color: var(--story-muted);
font-weight: inherit;
}
.story-step p {
max-width: 500px;
margin: 34px 0 0;
font-size: clamp(17px, 1.5vw, 21px);
line-height: 1.7;
color: var(--story-muted);
}
.story-step__cta {
display: inline-flex;
align-items: center;
gap: 14px;
width: fit-content;
margin-block-start: 36px;
padding-block-end: 5px;
border-block-end: 1px solid currentColor;
color: var(--story-text);
text-decoration: none;
font-size: 15px;
}
.story-step__cta span {
transition: transform 250ms ease;
}
.story-step__cta:hover span {
transform: translateX(4px);
}
/* Tablet / mobile */
@media (max-width: 900px) {
.product-story {
display: block;
}
.product-story__visual {
inset-block-start: 0;
z-index: 3;
height: 52vh;
background:
linear-gradient(
to bottom,
var(--story-bg) 80%,
rgba(10, 13, 18, .96)
);
}
.product-stage {
width: min(88%, 440px);
height: 44vh;
}
.product-stage__image {
max-height: 38vh;
}
.product-stage__meta {
inset-inline-end: 10px;
bottom: 8px;
}
.product-story__nav {
inset-inline-start: 18px;
}
.product-story__content {
border-inline-end: 0;
}
.story-step {
min-height: 78vh;
padding: 90px clamp(22px, 7vw, 60px);
}
}
@media (max-width: 560px) {
.product-story__visual {
height: 46vh;
}
.product-stage {
height: 40vh;
}
.product-stage__image {
max-height: 34vh;
}
.story-step {
min-height: 72vh;
}
.story-step h2 {
font-size: clamp(38px, 12vw, 60px);
}
}
/* Accessible motion fallback */
@media (prefers-reduced-motion: reduce) {
.product-stage__object,
.product-stage__light,
.story-step,
.product-story__nav button,
.story-step__cta span {
transition: none;
}
}
JavaScript #
<script>
(() => {
const story = document.querySelector('[data-product-story]');
if (!story) {
return;
}
const stage = story.querySelector('[data-product-stage]');
const steps = [...story.querySelectorAll('[data-story-step]')];
const navItems = [...story.querySelectorAll('[data-story-nav]')];
const number = story.querySelector('[data-product-number]');
const label = story.querySelector('[data-product-label]');
const reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
let activeIndex = -1;
let ticking = false;
/**
* Activate a story section and update the visual state.
*
* @param {number} index Story step index.
*/
function activateStep(index) {
const step = steps[index];
if (!step || index === activeIndex) {
return;
}
activeIndex = index;
steps.forEach((item, i) => {
const isActive = i === index;
item.classList.toggle('is-active', isActive);
if (isActive) {
item.setAttribute('aria-current', 'step');
} else {
item.removeAttribute('aria-current');
}
});
navItems.forEach((item, i) => {
const isActive = i === index;
item.classList.toggle('is-active', isActive);
item.setAttribute(
'aria-selected',
isActive ? 'true' : 'false'
);
});
stage.style.setProperty(
'--product-x',
step.dataset.x || 0
);
stage.style.setProperty(
'--product-y',
step.dataset.y || 0
);
stage.style.setProperty(
'--product-rotate',
step.dataset.rotate || 0
);
stage.style.setProperty(
'--product-scale',
step.dataset.scale || 1
);
stage.style.setProperty(
'--product-light',
step.dataset.light || 40
);
if (number) {
number.textContent = String(index + 1).padStart(2, '0');
}
if (label) {
label.textContent = step.dataset.label || '';
}
}
/**
* Find the story section whose center is closest
* to the center of the viewport.
*/
function findClosestStep() {
const viewportCenter = window.innerHeight / 2;
let closestIndex = 0;
let closestDistance = Infinity;
steps.forEach((step, index) => {
const rect = step.getBoundingClientRect();
const stepCenter = rect.top + (rect.height / 2);
const distance = Math.abs(
stepCenter - viewportCenter
);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = index;
}
});
activateStep(closestIndex);
}
/**
* Limit scroll calculations to one execution
* per animation frame.
*/
function updateStory() {
if (ticking) {
return;
}
ticking = true;
requestAnimationFrame(() => {
findClosestStep();
ticking = false;
});
}
window.addEventListener(
'scroll',
updateStory,
{ passive: true }
);
window.addEventListener(
'resize',
updateStory
);
navItems.forEach(button => {
button.addEventListener('click', () => {
const index = Number(
button.dataset.storyNav
);
const step = steps[index];
if (!step) {
return;
}
activateStep(index);
step.scrollIntoView({
behavior: reducedMotion.matches
? 'auto'
: 'smooth',
block: 'center'
});
/**
* Move keyboard focus to the selected heading
* without causing another scroll.
*/
const heading = step.querySelector('h2');
if (!heading) {
return;
}
/*
* Wait for smooth scrolling to begin before
* moving accessibility focus.
*/
window.setTimeout(() => {
heading.setAttribute(
'tabindex',
'-1'
);
heading.focus({
preventScroll: true
});
heading.addEventListener(
'blur',
() => {
heading.removeAttribute(
'tabindex'
);
},
{ once: true }
);
}, reducedMotion.matches ? 0 : 400);
});
});
findClosestStep();
})();
</script>
What the Code Actually Does #
There is an important implementation detail worth being precise about.
This version does not use IntersectionObserver.
It uses:
window.addEventListener(
'scroll',
updateStory,
{ passive: true }
);
and then throttles the work to one calculation per animation frame:
if (ticking) {
return;
}
ticking = true;
requestAnimationFrame(() => {
findClosestStep();
ticking = false;
});
For each frame that actually needs checking, findClosestStep() measures the center of each story block with getBoundingClientRect() and finds the one closest to the viewport center.
So the architecture is:
scroll event
↓
one RAF scheduled
↓
measure four steps
↓
find closest one
↓
state changed?
↓
yes → update CSS variables
no → do nothing
The important optimization is that activateStep() returns immediately when the same step remains active:
if (!step || index === activeIndex) {
return;
}
The expensive-looking visual transition is therefore mostly CSS interpolation.
JavaScript changes five values:
--product-x
--product-y
--product-rotate
--product-scale
--product-light
CSS handles the transition between them.
requestAnimationFrame() is appropriate for coordinating visual updates with browser repainting, but it is worth remembering that it does not automatically make repeated layout measurements free. The final production page should still be profiled.
Accessibility Should Be Designed In, Not Retrofitted #
This kind of interaction is exactly where I prefer to think about accessibility while building the component, not once the animation is finished.
It is much easier to decide what the navigation means, where keyboard focus should move, what motion is optional, and which text changes need announcing while the DOM is still being designed.
Trying to add all of that afterwards usually produces questions like:
Where should aria-current go?
Is this really a tab?
What receives focus?
Why does reduced motion still smooth-scroll?
The implementation already does several useful things.
The decorative glow uses:
aria-hidden="true"
The current product label is exposed through a polite live region:
aria-live="polite"
aria-atomic="true"
Reduced-motion preference is checked both in CSS and before programmatic smooth scrolling.
The navigation is made from real <button> elements.
And when a navigation button jumps to a story section, focus is moved to its heading without triggering another scroll.
There is, however, one semantic question I would decide before shipping.
The current markup declares:
role="tablist"
and each control uses:
role="tab"
aria-selected="..."
The WAI-ARIA tabs pattern assumes tabs control associated tab panels and defines keyboard interaction including arrow-key navigation between tabs.
Our four story articles are not actually layered tab panels; they all remain visible in the document and scrolling is the primary interaction.
So for production I would choose one of two approaches:
either implement the complete tabs keyboard pattern, or simplify this to ordinary navigation buttons and use aria-current to communicate the active story position.
I lean toward the second option.
ARIA should describe the interaction you actually built, not the component it visually resembles.
Why This Is Cheaper Than Full 3D #
The Watch is a good case for true real-time graphics because the object itself is the experience. Awwwards explicitly identifies it as a real-time WebGL piece and lists Three.js among the technologies.
But consider a property development.
Perhaps you have four arguments:
Architecture
Lobby
Balconies
Location
Do you really need a real-time 3D building model?
Sometimes yes.
Frequently, no.
One carefully prepared architectural render can remain sticky while its state changes:
Architecture → scale 1.00 / neutral position
Lobby → scale 1.08 / shift right
Balconies → scale 1.16 / shift left
Location → scale .95 / wider framing
Add lighting changes and the same image begins to participate in the narrative.
That gives us another useful middle layer:
Static landing page
↓
Sticky state-driven product story
↓
Image-sequence / crossfade story
↓
Full real-time 3D
Not every project should immediately jump from level one to level four.
Crossfading Multiple Product Images #
The next version becomes more interesting if you have several photographs or renders of the same object.
For example:
<picture>
<img
data-product-image
src="product-front.webp"
alt=""
>
</picture>
A step can declare another asset:
<article
data-story-step
data-image="product-detail.webp"
>
Instead of replacing the source abruptly, keep two image layers and crossfade them.
Now the story can move through:
full product
↓
slight reposition
↓
material close-up
↓
mechanical detail
↓
full product again
With careful photography, the transition can feel surprisingly spatial even though every asset is still a normal WebP or AVIF image.
For many commercial sites, four excellent prepared images can deliver much of the perceptual value of a 3D presentation with a fraction of its implementation complexity.
That is not a universal performance equation.
It is a design strategy.
WordPress + ACF #
This component maps cleanly to an ACF repeater:
product_story
product_image
steps (Repeater)
number
title
title_muted
text
label
product_x
product_y
product_rotate
product_scale
product_light
But I would not expose these controls directly to the client:
product_x
product_y
product_rotate
product_scale
Because eventually somebody will discover:
rotate: 740deg
scale: 4.8
and ask why the product looks slightly large.
Instead, I would expose constrained states:
visual_state
01 — Default
02 — Detail
03 — Close
04 — Final
PHP maps those editorial choices to art-directed values:
<?php
$states = [
'default' => [
'x' => -2,
'y' => 0,
'rotate' => -4,
'scale' => 1,
'light' => 35,
],
'detail' => [
'x' => 4,
'y' => -2,
'rotate' => 5,
'scale' => 1.05,
'light' => 68,
],
'close' => [
'x' => -5,
'y' => 3,
'rotate' => -7,
'scale' => 1.13,
'light' => 82,
],
'final' => [
'x' => 2,
'y' => -2,
'rotate' => 2,
'scale' => .94,
'light' => 48,
],
];
Then inside the repeater:
<?php
$state_name = get_sub_field('visual_state') ?: 'default';
$state = $states[$state_name] ?? $states['default'];
?>
<article
class="story-step"
data-story-step
data-label="<?= esc_attr(get_sub_field('label')); ?>"
data-x="<?= esc_attr($state['x']); ?>"
data-y="<?= esc_attr($state['y']); ?>"
data-rotate="<?= esc_attr($state['rotate']); ?>"
data-scale="<?= esc_attr($state['scale']); ?>"
data-light="<?= esc_attr($state['light']); ?>"
>
Content remains editable.
Art direction does not become communal farmland.
Where I Would Use This First #
Real estate is probably my first practical use case.
Keep the building render permanently visible:
BUILDING
and let the content move through:
01
Architecture
02
Lobby
03
Balconies
04
Location
The render changes scale and position. In one or two states, it crossfades to another prepared render.
Furniture is another obvious fit:
SOFA
↓
shape
↓
fabric
↓
mechanism
↓
dimensions
Automotive works almost automatically:
CAR
↓
silhouette
↓
cabin
↓
lighting
↓
performance
The same pattern could work for industrial equipment, medical devices, cosmetics, architecture, premium consumer electronics, or even a SaaS product where one interface screenshot remains central while individual capabilities are revealed around it.
The commercial value is not the movement itself.
It is continuity.
Instead of treating every feature as a disconnected card, the page progressively teaches the user about one object.
Two Other Fresh References #
Space Capital is useful for a different reason. CSS Design Awards lists it with animated, grid and WebGL tags and describes the redesign as making a decade of space-economy authority more visible and explorable.
https://www.cssdesignawards.com/sites/space-capital/49996/
Graffico Office goes deliberately immersive. Its CSSDA description presents the agency itself as a 3D office visitors can enter and explore.
https://www.cssdesignawards.com/sites/graffico-office/49998/
These are good reminders that WebGL does not need to be avoided.
It simply needs to earn its place.
When exploration itself is the idea, a real 3D environment makes sense.
When the goal is to explain four product benefits, a sticky image and five state variables may be the better engineering decision.
As for Codrops Webzibition: during the August 15–17 window there were no new entries I wanted to force into this research purely to fill the list. The live feed has since moved on; as of August 18 it shows a newer Atlas Motion Systems entry.
That is why I prefer keeping these notes dated rather than pretending an inspiration feed stands still.
Frequently Asked Questions #
What is sticky product storytelling? #
Sticky product storytelling keeps a product visual fixed or sticky while adjacent sections scroll. Each section changes the product’s visual state, such as scale, position, rotation, lighting, image, or label.
Can I build this without Three.js? #
Yes.
If the product does not need genuine real-time geometry, lighting, shaders or camera movement, CSS transforms and prepared images can create a convincing product narrative without a WebGL renderer.
Does this implementation use IntersectionObserver? #
No.
The implementation shown here uses a passive scroll listener, requestAnimationFrame(), getBoundingClientRect(), and an active-index guard.
An IntersectionObserver-based version would also be possible, but it would be a different implementation.
Why use position: sticky? #
position: sticky lets the visual remain attached to a viewport threshold while its surrounding content continues scrolling through the normal document flow.
Is requestAnimationFrame enough for scroll performance? #
It helps coordinate the measurement/update work with rendering and prevents the callback from running multiple times within the same frame.
It does not remove the cost of layout reads such as getBoundingClientRect(), so the final page should still be tested in browser performance tools.
How should prefers-reduced-motion work here? #
The essential information should remain available while non-essential transition effects are removed or significantly reduced.
Programmatic smooth scrolling should also respect the preference, as this implementation already does.
Can I use different images instead of transforming one image? #
Yes.
Crossfading a small set of carefully aligned product images is one of the strongest extensions of this pattern. It can create a spatial impression without requiring a real-time 3D scene.
Conclusion #
The useful idea I took from The Watch is not that every premium product page now needs Three.js.
The useful idea is that the product can remain the visual constant while the story changes around it.
That can be implemented at very different technical levels.
At one end:
one image
CSS transform
four states
At the other:
3D model
WebGL
Three.js
real-time rendering
Both can be correct.
The engineering decision is choosing the smallest version that still delivers the intended experience.
For many commercial projects, I think the sweet spot is somewhere in the middle: one sticky object, a few intentionally designed visual states, carefully controlled motion, proper keyboard and accessibility behavior, and perhaps two or three crossfading assets.
The page still feels like one continuous product experience.
The implementation remains ordinary HTML, CSS and JavaScript.
And the renderer can have the afternoon off.