Notes for coding agents working on this repo. Read README.md first for what the demo is.
- There is no build step, no package.json and nothing to install. Plain HTML, CSS and ES modules.
- Serve the root with any static server (
npx serve .orpython3 -m http.server). ES modules don't load fromfile://. - GSAP (3.15.0) and Lenis (1.3.26) are vendored in
js/and loaded as classic scripts before the module, sogsapandLenisare globals. Don't import them. To upgrade one, replace the file in place fromhttps://cdn.jsdelivr.net/npm/<pkg>@<version>/dist/…(Lenis with its.map). - The fonts come from the Adobe Fonts kit linked in
index.html(halyard-display400 and 500,owners-xnarrow700). - Effects 05 and 06 draw with raw WebGL in
js/webgl/(WebGL2, or WebGL1 as a fallback). There's no three.js. - There are no automated tests. Check a change in a browser: open items on both halves of the screen in every section, close with Close and with Escape, do it again from the keyboard (Tab, Enter), then with reduced motion and at phone width, and look at the console.
| Path | What it is |
|---|---|
index.html |
The only page: the frame, six effect sections (a .heading and a .grid of 16 .grid__item figures each), the shared .panel and the footer. |
js/index.js |
The effect: config, per-item overrides, the movers (flat and WebGL), the panel reveal and close, the reduced-motion crossfade, scroll lock, focus handling and start-up. |
js/webgl/Sheets.js |
The WebGL renderer for silk and ink movers: the .sheets canvas, the sheet mesh, the texture and one compiled program per surface. It only draws; js/index.js animates the sheets. |
js/webgl/silk.js, js/webgl/ink.js |
One surface each: its vertex and fragment shaders. |
js/webgl/glsl.js |
GLSL shared by the surfaces: noise/fbm, cover() and project(). |
js/utils.js |
preloadImages (loads and decodes CSS background images) and preloadFonts. |
js/smoothscroll.js |
Lenis, driven by GSAP's ticker. Kept on window.lenis so the effect can stop it; null under reduced motion. |
js/gsap.min.js, js/lenis.min.js |
The vendored libraries (plus lenis.min.js.map). |
css/base.css |
All styles: loader, frame, headings, grid, panel, .mover, the .sheets canvas and .scroll-locked. |
assets/ |
img1.webp…img33.webp, all 960×1200 (4:5). |
favicon.ico |
Not linked from the page, which uses tympanus.net's favicon. |
Clicking (or Enter/Space on) a grid item flies copies of its image, the "movers", along a path from the item to the panel, then reveals the panel image and its caption. Closing fades the panel out and the grid back in.
- Start-up. Images and fonts preload, then
body.loadingis removed andinit()binds the items, the Close button and Escape. - Open (
onGridItemClick):setScrollLock(true)adds.scroll-lockedto<html>and stops Lenis, so the page can't move under the transition.- The item's
data-*overrides are merged intoconfig. positionPanelBasedOnClickputs the panel on the other half of the screen (panel--rightwhen the item is left of center) and, withautoAdjustHorizontalClipPath, turns aleft-right/right-leftclip direction to match.- The item's image,
h3andpare copied into the panel, andsetPanelInteractive(true)makes the panel reachable and the rest of the pageinert. - Every
.grid__itemon the page fades out, staggered by distance from the clicked one (all sections, not only the clicked one).
- Movers.
generateMotionPathinterpolatesstepsrects between the item's image and the panel image (both ends excluded), offset by the sine path and the wobble. Each mover is aposition: fixeddiv with the image as its background and az-indexof1000 + index. It's clipped in, held formoverPauseBeforeExit, and clipped out in the same direction.scheduleCleanupremoves them once the last one ends. The panel image reveals with the same clip direction aftersteps * stepInterval. WithmoverSurface: 'silk'or'ink'(and WebGL available),animateSheetTransitionreplaces the DOM movers and the clip-path panel reveal. The clicked image, each copy along the same path, and the panel image become sheets on the.sheetscanvas (z-index1500, above the grid and under the panel), drawn with that surface's shaders.- The clicked image's sheet replaces it on the same frame and leaves (silk rolls it away, ink dissolves it).
- The copies follow the same timing as DOM movers, calmer the closer they are to the panel. They reveal up to
moverRevealAmount, so ink copies can stay irregular blots. - The last sheet comes in over the panel's rect and settles still. Ink soaks it in from the side the copies arrive from. The DOM panel image then fades in over it for 0.3s and the canvas stops.
- Open state. When the caption has faded in (and, for WebGL movers, the canvas has stopped),
onPanelRevealedsetsisAnimatingto false andisPanelOpento true, and moves focus to Close. - Close (
resetView): focus goes back to the item, the panel fades out, the frame and items fade back in (scaling up from 0.8), and the scroll lock is released on complete.configis reset tooriginalConfig.
Rules that keep it working:
- Measure after locking scroll. Anything that reads a rect in the open path goes after
setScrollLock(true). isAnimatinggates everything. Clicks, Close and Escape are ignored mid-transition.configis mutated per click and reset on close. A new option needs a default inconfig(with a comment, like the others) and a reader inextractItemConfigOverrides, or itsdata-*attribute is ignored.- Clip paths are
inset()strings with all four values in%, so GSAP can tween between them.getClipPathsForDirectionholds the four directions. - Reduced motion uses
crossfadeToPanelinstead: no movers, no scaling, and no Lenis (native scroll). Any new effect needs its reduced path to stay a plain fade. WebGL movers never run under reduced motion. - WebGL movers fall back to flat ones when WebGL isn't available (
usesSheets()), so their sections also work with the plain clip-path copies.moverBlendModehas no effect on sheets. - The panel carries
inertin the HTML and is only interactive while open. Its.panel__imgis therole="img", labelled with the item's title.
The variations are the six effect sections on index.html. Each figure in a section carries the same data-* settings.
| # | Section | What it does | Settings (data-* on each figure) |
|---|---|---|---|
| 01 | Shane Weber | Straight path, six movers, clipped top to bottom, sine easing, no rotation. | None: the config defaults. |
| 02 | Manika Jorge | Eight movers, each tilted up to ±7°, a longer hold, power2 exits. | steps 8, rotation-range 7, step-interval 0.05, mover-pause-before-exit 0.25, eases sine.in / power2 / power2. |
| 03 | Angela Wong | Ten movers on a 300px sine arc, clipped sideways, slow power4 panel reveal. | steps 10, step-duration 0.3, path-motion sine, sine-amplitude 300, clip-path-direction left-right, step-interval 0.07, mover-pause-before-exit 0.3, eases sine / power4 / power4, panel-reveal-duration-factor 4. |
| 04 | Kaito Nakamo | Four quick movers clipped bottom to top, hard-light blend, slow expo reveal. |
steps 4, clip-path-direction bottom-top, step-duration 0.25, step-interval 0.06, mover-pause-before-exit 0.2, eases sine.in / expo / expo, panel-reveal-duration-factor 4, mover-blend-mode hard-light. |
| 05 | Noor Halvorsen | Silk: the image peels off and six WebGL sheets rise on an arc, rippling and catching the light. They unroll top to bottom behind a soft, wavy edge, and the last one settles flat into the panel. | mover-surface silk, steps 6, step-duration 0.5, step-interval 0.08, mover-pause-before-exit 0.15, eases sine.out / sine.in / power2.inOut, panel-reveal-duration-factor 2.4, path-motion sine, sine-amplitude -140, surface-strength 0.3, edge-noise 0.4. |
| 06 | Mila Serrano | Ink: the image dissolves and six WebGL copies bloom as soft, oval ink blots of the picture. They swirl, pool darker at their edges, and thin into wisps as they sink along an arc. The panel image soaks in from the side they arrive from. | mover-surface ink, eases sine.out / sine.inOut / sine.inOut, path-motion sine, sine-amplitude 90, rotation-range 3, surface-strength 0.8, edge-noise 0.5, mover-reveal-amount 0.58. Timing is the config defaults, the same as 01. |
Notes:
- Section 03's figures also carry
data-auto-adjust-horizontal-clip-path="true", which isn't read. The option is on by default inconfig. - Images: section 01 uses
img1–img16, 02 usesimg17–img32, 03 usesimg33thenimg1–img15, and 04 usesimg16–img31. Section 05 reuses sixteen of the most fabric-led images, and section 06 the rest exceptimg33, each in the order of its markup. - 01–04 are the author's original effects. 05 and 06 were added after the original release.
- Copy the last
.headingand.gridblock inindex.htmland paste it after it. Give the heading a new name and aneffect 0N: …line in.heading__meta. - Set the effect's
data-*attributes on every figure in the new grid, all with the same values. Only attributes thatextractItemConfigOverridesreads do anything. For WebGL sheets, adddata-mover-surface="silk"or"ink"and tunedata-surface-strength,data-edge-noiseanddata-mover-reveal-amount. - If the effect needs a new option, add its default to
config, a reader toextractItemConfigOverrides, and use it in the animation code. Give it a reduced-motion path if it adds motion outside the movers. - Keep each figure's markup:
class="grid__item" role="button" tabindex="0" aria-labelledby="captionN", with ids continuing fromcaption96. Inside it go a.grid__item-imagewith an inlinebackground-image, and afigcaptionwith anh3(the title) and ap(the model line, hidden in the grid and shown in the panel). - New images go in
assets/as 4:5 WebP (the others are 960×1200).preloadImagespicks up every.grid__item-imageby itself. - There's no variations nav, since every effect lives on
index.html. If variations ever get their own pages, add a compact numbered "Variations 01 02 …" nav to the frame on every page, with the current page's entry markedframe__demo--current.
- Scrolling. Lenis runs on GSAP's ticker with lag smoothing off. Stop and start it only through
setScrollLock, which also handles native scrolling when Lenis is off.htmlhasscrollbar-gutter: stable, so locking doesn't shift the layout where scrollbars take up space. - Stacking. The frame sits at
z-index1000, movers at1000 + index, and the panel at 2000. - Panel size. The panel is
100svhtall and its image width comes from--panel-img-size. Keepsvh, sincevhputs the caption and Close under mobile browser toolbars. - Text case.
bodyhastext-transform: lowercase. Write titles and captions in normal case in the HTML. - Loader.
body.loadingcovers the page untilpreloadImagesandpreloadFontsboth resolve. If the fonts change, update the list passed topreloadFontsinjs/index.js. - Focus. Links, grid items and Close show a
2px solid redoutline on:focus-visible. Close only turns black on hover and keyboard focus, so it stays red when focus moves to it after a click.
- Space. Sheets are placed in CSS px, like the DOM movers. The rect is
[left, top, width, height]fromgetBoundingClientRectorgenerateMotionPath, with uv running 0 to 1 and y down. Depth is projected around the middle of the viewport with a 1000px perspective, the same as CSSperspective: 1000px. - Surfaces. A surface is a file in
js/webgl/exportingvertexShaderandfragmentShader, registered inSURFACESinSheets.js. Every surface is compiled when the renderer is created. Surfaces can use any of the uniforms inUNIFORMSand ignore the rest. - Values GSAP animates on each sheet:
revealandhide(0 → 1, the edge sweeping or spreading in and out),opacityandstrength(silk: the depth of the folds, as a fraction of the sheet's width; ink: how much the picture swirls).rect,travel,wipe,origin,rotation,edgeNoiseandseedare fixed when the sheet is added. - Silk edges follow the clip-path directions.
WIPESinjs/webgl/Sheets.jsmust stay in step withgetClipPathsForDirectioninjs/index.js.left-rightis the one that sweeps from right to left, as its clip-paths do. - Ink spreads from
origin(uv), in an oval that follows the sheet's proportions. The ink blur is a mipmap bias on the texture lookup, so it costs no extra passes (and shows only on WebGL2). - The rest state must match the DOM exactly. At
reveal1,hide0 andstrength0, every surface draws the sheet flat, unshaded, unblurred and sampled likebackground-size: cover. That's what lets the clicked image's sheet replace it on one frame and the landing sheet hand over to the panel image. Keep every distortion multiplied bystrength(or by how farreveal/hideare from rest), and keep edges starting and ending fully outside the sheet. - One texture per transition.
load()decodes the clicked image (already in the cache from the preload) and uploads it with mipmaps on WebGL2, so the small copies don't shimmer.stop()frees the texture and hides the canvas. The canvas draws only betweenstart()andstop(), at a device pixel ratio capped at 2. - The renderer is created on first use and kept. If WebGL or any surface's shaders fail,
getSheets()returnsnulland the section falls back to flat movers. - GLSL ES 1.00. Don't use reserved words or built-in names (
step,length,distance…) as variable names.
- Two-space indentation, single quotes, semicolons, trailing commas (in function arguments too) and lines up to about 100 characters, as in
js/index.js, which is formatted Prettier-style. Follow what's in the file. - Functions are
constarrow functions, each with a one-line//comment above it saying what it does. Config entries get a trailing comment. A few comments are marked with ✨; leave them. js/utils.jsdocuments its functions with JSDoc.js/webgl/Sheets.jsis a class with a one-line comment above each method. Each surface file opens with a comment saying what it looks like, and the shaders are commented inside.- CSS: custom properties on
:root, BEM-style names (grid__item,panel--right), and native nesting for states and media queries in some rules. - No dependencies and no build tooling unless asked.
- The
<title>, meta description and keywords, theframe__titleheading, the More info and Code links, and the tags. - The section names, the effect descriptions, the item titles and model names, and the panel's placeholder text.
- The four original effects and their settings, which are the author's own.
- The credits in the README and the Adobe Fonts kit.