Horizontal Scroll Scenes
Meng ToCommunityCreated by Meng To
- Skill
- Guidelines
- Software development
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a technical framework for creating multi-scene, horizontal storytelling experiences where vertical scrolling drives horizontal movement. By using a pure function to derive scene state from the scroll position, the implementation ensures that navigation remains consistent across page refreshes, anchor jumps, and scrollbar interactions. It emphasizes avoiding wheel hijacking and scroll-snap in favor of native scroll behavior to maintain accessibility and performance.
What it does
- Maps vertical scroll position to horizontal scene transitions using a pure state function.
- Provides logic for handling dwell times, scene transitions, and progress UI updates.
- Includes strategies for managing keyboard navigation, reduced-motion preferences, and focus management.
- Offers a method for implementing glide-to-scene links that skip intermediate content.
- Details techniques for visual transitions like burn-wipes and camera movement.
- Addresses common failure modes like overlapping scenes, incorrect index labeling, and scroll-behavior conflicts.
When to use it
- When building scrollytelling sites that require discrete chapters with resting beats.
- When you need a reliable state machine that survives browser refreshes and back-button navigation.
- When you want to avoid the pitfalls of wheel-hijacking or scroll-snap libraries.
- When creating multi-scene product tours that move horizontally on a fixed stage.
Advantages
- Native scroll behavior ensures compatibility with touch, trackpads, and browser features.
- State is derived rather than accumulated, preventing desync issues.
- Supports complex navigation like fast-skipping without strobing intermediate scenes.
- Provides clear guidance on accessibility, including screen reader support and reduced-motion handling.
Usage examples
- “How do I ensure my horizontal scrollytelling site doesn't break when a user refreshes the page?”
- The skill advises deriving the current scene state directly from the scroll position (scrollY) rather than storing it in a variable, ensuring the correct scene is always rendered upon reload.
- “How can I implement a 'jump to chapter' link that doesn't strobe through every scene in between?”
- The skill provides a fast-skip logic that scrolls to the target station and plays only the final transition, preventing the visual flickering of intermediate scenes.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/mengto/horizontal-scroll-scenes/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip horizontal-scroll-scenes.zip -d .agents/skills
Documentation by Meng To
Horizontal Scroll Scenes
The mechanism: scroll position is the only state. One pure function turns scrollY / (scrollHeight - innerHeight) into {scene, dwell 0→1, move 0→1} every frame, and everything on screen (camera, wipe line, craft clock, text, index) is written from that triple. Nothing remembers where you were, so every beat is a place on the page.
This is what Kibori does, and it is not a translateX slider. Verified by running it: the page is a 1742vh native-scroll track (15,678 px at 1440×900); a fixed stage holds one WebGL world whose camera travels 0 → 49.7 world units left to right through eight stations; between stations a ragged burn line wipes from a frozen frame of the scene being left to the live scene already framed on the next. There is no wheel handler, no snap, no stored "current chapter". Reload at depth, scrollbar drag and anchor jumps work because state is derived, not accumulated.
Reach for scroll-world-storytelling or build-threejs-scroll-worlds for the story and 3D-world side, gsap-scrolltrigger-storytelling for pinned sticky sections, scroll-progress-timeline for a progress line. Reach for this when the deliverable is discrete chapters with a resting beat each, laid along an X axis, and the hard part is making that state machine survive reverse, skip and refresh. The demo is Vanilla JavaScript + DOM/CSS + inline SVG; the source is three.js. The state machine is identical.
The pace: two weights and a track height
Every number lives in DWELL, MOVE and the track height. One unit is one screen of scrolling.
| constant | value | meaning |
|---|---|---|
DWELL | 1.15 | scroll spent inside a scene while its craft advances (about 1,030 px at 900 tall) |
MOVE | 0.85 | scroll spent crossing to the next scene (about 760 px); the last scene has none |
CYC | 2.00 | one scene per two screens |
SPAN | N*DWELL + (N-1)*MOVE | 8 scenes: 15.15 plus a 1.35 footer = 16.5; demo, 5 scenes: 9.15 |
| track | (SPAN + 1) * 100vh | so 1 unit ≈ 1 screen; Kibori is 1742vh for 16.5 |
function travel(sp){ // sp = scrollY / (scrollHeight - innerHeight)
const u = Math.min(SPAN - 1e-5, sp*SPAN);
const i = Math.min(N - 1, Math.floor(u/CYC));
const local = u - i*CYC;
const dw = Math.min(1, local/DWELL); // the scene's own clock
const f = i >= N-1 ? 0 : clamp01((local - DWELL)/MOVE); // the move
const rc = clamp01((f - 0.01)/0.97); // the line's own clock
return {i, u, dw, f, fe: sstep(0,1,f), L: -0.08 + 1.24*rc, // L: line, off-frame at both ends
live: Math.min(N-1, f < 0.39 ? i : i+1)}; // what the index shows
}Normalise by scrollHeight - innerHeight, never a hard-coded pixel height: a phone's collapsing URL bar changes innerHeight mid-scroll and a px map drifts.
Rules, each with the failure it prevents
Derive, do not accumulate. Never keep
currentSceneor add wheel deltas. Stored state desyncs on scrollbar drag,Home/End, anchor jumps, back-forward and refresh. Prove it: record a signature of every visible style at 20uvalues going forward, then again in reverse, then by jumping straight to each, then afterreload()at three depths. All must be equal (the demo run: 0 mismatches of 21, 0 skip, 0 reload).No wheel hijack, no
scroll-snap. Hijacking breaks touch, trackpad momentum,Space/PageDownand find-in-page. Snap fights the dwell, which is already the resting plateau (1.15 screens where nothing moves but the craft). Native scrolling stays 1:1: 2,000 px of wheel gave exactlyu = 2000/(scrollHeight - vh)*SPAN.Pin the craft clock outside its dwell. Scene
k's progress is1ifk < i,dwifk == i,0after. Then nothing snaps back behind you and reverse retraces exactly. Write it as--p{k}and eased--e{k}custom properties on the stage; both world copies read them, so it is one write per frame.A move with nothing moving is dead scroll. Start the line at the frame's edge almost at once (
rc = (f-0.01)/0.97); starting it further off made the first eighth of every move do nothing.The wipe hides the camera. Freeze the outgoing frame ahead of the line and have the live world already standing on the destination behind it. A camera still travelling under the line reads as a double exposure. Demo: layer A at camera
iclipped to the far side of the line, layer B ati+1clipped to the near side. Pan mode (translateX(-(i+fe)/N*100%)) is the alternative when no wipe is wanted.The line writes the next chapter in. Alternate sweep direction from the side the incoming words sit on. Mask outgoing text
linear-gradient(a, transparent (L-.01)%, #000 (L+.016)%)and incoming#000 (L-.062)%, transparent (L-.048)%, witha = 90degor270deg. Two chapters never share a patch of frame. On a phone both sit in one slot: hide the incoming until the line has cleared,sstep(1.00, 1.14, L).The index flips at
f < 0.39, not 0.5. At the midpoint the incoming title is already half written; flipping late labels the wrong scene. Fade the inactive rows to0.55, not0.Chapter clocks overlap on purpose. Arrival starts
MOVE*0.80before the scene's home and finishesDWELL*0.62into it; departure starts atDWELL + MOVE*0.28and takesMOVE*0.34. Each part (eyebrow, title, lede, numbers) takes its own slice ofarrive(0, .12, .40, .60 plus a 0.30 ramp).Fast-skip plays only the last move. More than one scene away: roll smoke in over 380 ms,
scrollTothe station before the target ((tgt-1)*CYC + DWELL*0.96going forward,(tgt+1)*CYC + 0.02going back), then play the final move for 2,600 ms with1-(1-x)^2.4as the veil lifts over the first 24%. Hide the scene it passes through, or its words flash. Land attgt*CYC + DWELL*0.80so the target is assembled. One scene away or fewer: plain leg ofclamp(700 + |dy|/vh*150, 900, 2400)ms with cubic in-out. Without this a jump from 1 to 8 strobes six fires.Any wheel, touch or nav key cancels the glide. Otherwise the page fights the hand.
html{scroll-behavior:smooth}breaks both the glide and every capture. Per-framescrollTochases itself; playwright screenshots land mid-scroll and look like overlap bugs. Setauto.Hidden chapters are
visibility:hiddenplusinert. Opacity 0 alone leaves invisible links focusable and readable to screen readers.Render on
scroll,resize,pageshow,loadandfonts.ready. Reload restores scroll after first paint; a bfcache restore fires no scroll event. Guard a zero-size stage and size from aResizeObserver.Duplicate worlds need unique SVG ids. Two copies sharing a
clipPathid resolveurl(#id)to the first one only.Announce committed changes only. A polite live region says "Scene 4 of 5: Yakisugi" once when a glide lands, not on every threshold crossed on the way.
Accessibility and lifecycle
Index rows are real links with
aria-current="step"and a visible 1 px outline at 5 px offset; brand link returns to scene 1. The stage isposition:fixedabove a plain-height track soSpace, arrows,PageDown,HomeandEndscroll natively.prefers-reduced-motion: reduce: no line, no travel. Scene B cross-fades overf0.30 → 0.70, the incoming chapter waits untilf = 0.64, glides are an instantscrollTo, and the craft still advances with scroll (that is the user's own input). Controls stay live.document.hidden: settle any glide at its target so a resumed tab does not integrate the pause.Mobile: keep the index to the current row (
03 / 05 Kigumi), stack the chapter at the foot and the work above it.
Cost, measured
The state machine is free: render() sits at the 0.1 ms timer floor (p50 0, p90 0.1) over a 400-step sweep, and 8.3 ms p50 (120 Hz cap) with 9.2 ms max during a 2.6 s glide in the demo. The cost is whatever the scenes draw. In Kibori a dwell frame was slower than a transition until a 4096² shadow map stopped redrawing every frame, and a scissored live scene did the rest; see the kibori-60fps notes. Never spend effort optimising travel().
Failure signatures
| you see | cause |
|---|---|
| reload lands on scene 1 | state kept in JS; derive from scrollY and render on load/pageshow |
| scene "sticks" between two beats | snap or wheel capture; delete it |
| two rooms overlap under the line | camera still travelling under the wipe |
| index shows the old scene as the title lands | flip threshold at 0.5 instead of 0.39 |
| jump link strobes several scenes | glide plays every move instead of the last |
| jump link overshoots or stutters | scroll-behavior:smooth on html |
| second scene's clip does nothing | duplicated SVG id |
| screen reader reads chapters that are not on screen | opacity 0 without inert |
| phone shows two titles spliced at the line | incoming chapter not held back until the line clears |
Provenance
Extracted from KIBORI 木彫, an eight-craft Kyoto woodshop page in one three.js file (kibori.html, travel() and the glide block near the end of its module script). Demo staged in that page's dark-and-ember look with five hand-drawn SVG crafts.
Files
- ATTRIBUTION.mdAdded by AutoGineer870 B
- LICENSELicence file added by AutoGineer1.0 KiB
- PROMPT.md2.5 KiB
- SKILL.md9.9 KiB
- agents/openai.yaml445 B
- demo.html37.3 KiB
- preview.jpg147.0 KiB
demo.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Horizontal Scroll Scenes — KIBORI 木彫</title>
<meta name="description" content="Five scenes laid along a horizontal axis, driven by nothing but vertical scroll position: one pure function turns scrollY into scene, dwell and move.">
<meta name="theme-color" content="#0a0806">
<meta name="color-scheme" content="dark">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230a0806'/%3E%3Crect x='14' width='4' height='32' fill='%23ff8a2b'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@400&display=swap&text=%E6%9C%A8%E5%BD%AB%E5%B7%A5%E6%88%BF%E7%B5%84%E5%AD%90%E9%9D%92%E6%B5%B7%E6%B3%A2%E6%A9%8B%E7%84%BC%E6%9D%89%E6%BC%86" rel="stylesheet">
<style>
:root{
--ink:#f2ece2;
--dim:rgba(242,236,226,.62);
--hair:rgba(242,236,226,.16);
--ember:#ff8a2b;
--ember-hot:#ffd58a;
--char:#0a0806;
--sans:"Inter",-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,sans-serif;
--serif:"Instrument Serif",Georgia,"Times New Roman",serif;
--mincho:"Shippori Mincho","Hiragino Mincho ProN","Yu Mincho",serif;
--n:5; /* scenes */
--span:9.15; /* 5 dwells of 1.15 + 4 moves of 0.85, in screens */
}
*{margin:0;padding:0;box-sizing:border-box;font-weight:normal}
/* never smooth: the glide is ours, and a smooth html makes every per-frame scrollTo chase itself */
html{scrollbar-width:none;scroll-behavior:auto;background:#0a0806}
html::-webkit-scrollbar{display:none}
html,body{min-height:100%}
body{background:#0a0806;color:var(--ink);font-family:var(--sans);overflow-x:hidden;-webkit-font-smoothing:antialiased}
/* the whole piece is this height: (span + 1) screens, so one unit of the state machine is one screen of scrolling */
#track{height:calc((var(--span) + 1) * 100vh)}
#stage{position:fixed;inset:0;overflow:hidden;background:#0a0806}
/* ---------------- the world: one strip, scenes side by side ---------------- */
.world{position:absolute;left:0;top:0;height:100%;width:calc(var(--n) * 100%);will-change:transform}
.layer{position:absolute;inset:0;overflow:hidden}
.layer.b{visibility:hidden}
.scene{position:absolute;top:0;height:100%;width:calc(100% / var(--n));left:calc(var(--i) * 100% / var(--n));overflow:hidden}
.bg{position:absolute;inset:0;
background:
radial-gradient(46% 42% at var(--wx,68%) 30%,rgba(255,196,130,.20),rgba(255,160,80,.06) 55%,transparent 75%),
linear-gradient(180deg,#120d09 0%,#0c0906 58%,#17100a 58.2%,#0f0a06 100%);
}
.bg::before{content:"";position:absolute;inset:0;
background:repeating-linear-gradient(90deg,transparent 0 21%,rgba(0,0,0,.42) 21% 22.4%,rgba(60,38,20,.30) 22.4% 23%,transparent 23% 50%);
opacity:.8}
.bg::after{content:"";position:absolute;left:0;right:0;bottom:0;height:42%;
background:repeating-linear-gradient(90deg,rgba(0,0,0,.28) 0 2px,transparent 2px 76px);opacity:.55}
.window{position:absolute;left:var(--wx,68%);top:7%;width:18%;height:14%;transform:translateX(-50%);
background:
repeating-linear-gradient(90deg,transparent 0 calc(25% - 1.5px),rgba(40,24,12,.85) calc(25% - 1.5px) 25%),
repeating-linear-gradient(0deg,transparent 0 calc(33.3% - 1.5px),rgba(40,24,12,.85) calc(33.3% - 1.5px) 33.3%),
linear-gradient(180deg,rgba(255,232,196,.42),rgba(255,206,150,.20));
box-shadow:0 0 0 3px rgba(40,24,12,.9),0 0 90px 10px rgba(255,170,90,.12)}
.art{position:absolute;width:min(42vw,720px);top:52%;transform:translateY(-50%)}
.art svg{display:block;width:100%;height:auto;overflow:visible}
.art.r{right:clamp(30px,6vw,120px)}
.art.l{left:clamp(30px,6vw,120px)}
.shadow{position:absolute;left:8%;right:8%;bottom:-6%;height:5%;background:radial-gradient(closest-side,rgba(0,0,0,.6),transparent);}
/* ---------------- the burn ---------------- */
.fx{position:absolute;inset:0;pointer-events:none;visibility:hidden;overflow:hidden}
.veil{position:absolute;top:0;bottom:0;width:34%;background:linear-gradient(90deg,rgba(10,8,6,.88),rgba(10,8,6,.52) 40%,rgba(10,8,6,0))}
.veil.rev{background:linear-gradient(270deg,rgba(10,8,6,.88),rgba(10,8,6,.52) 40%,rgba(10,8,6,0))}
.glow{position:absolute;top:0;bottom:0;width:240px;margin-left:-120px;mix-blend-mode:screen;
background:linear-gradient(90deg,transparent,rgba(255,120,30,.10) 30%,rgba(255,150,60,.34) 50%,rgba(255,120,30,.10) 70%,transparent)}
.flame{position:absolute;top:0;bottom:0;width:76px;margin-left:-38px;
background:linear-gradient(90deg,#0a0806 0%,#3a1a08 24%,#ff7a1f 42%,var(--ember-hot) 50%,#ff7a1f 58%,#3a1a08 76%,#0a0806 100%)}
/* smoke that covers a long jump */
#smoke{position:absolute;inset:0;background:radial-gradient(90% 70% at 50% 50%,#1a120c,#0a0806 70%);opacity:0;visibility:hidden;pointer-events:none;z-index:6}
/* ---------------- chrome ---------------- */
.chrome{position:fixed;inset:0;z-index:5;pointer-events:none}
.chrome > *{pointer-events:auto}
.bar{position:absolute;z-index:3;left:0;right:0;top:0;height:60px;padding:0 30px;display:flex;align-items:center;gap:26px;font-size:14px;letter-spacing:.01em;pointer-events:none}
.bar > *{pointer-events:auto}
.brand{color:var(--ink);text-decoration:none;font-size:16px;letter-spacing:.02em}
.brand i{font-style:normal;font-family:var(--mincho);font-size:14px;margin-left:8px;opacity:.72}
.bar .push{flex:1;pointer-events:none}
.modes{min-width:0;display:flex;align-items:center;gap:0;border:1px solid var(--hair);border-radius:999px;padding:3px;background:rgba(10,8,6,.55)}
.modes legend{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
.modes label{position:relative;cursor:pointer}
.modes input{position:absolute;opacity:0;inset:0;margin:0;cursor:pointer}
.modes span{display:block;padding:8px 16px;border-radius:999px;font-size:13px;color:var(--dim);transition:background .2s ease,color .2s ease}
.modes input:checked + span{background:var(--ink);color:#0a0806}
.modes input:focus-visible + span{outline:1px solid var(--ink);outline-offset:3px}
.modes-note{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--dim)}
a:focus-visible{outline:1px solid var(--ink);outline-offset:5px}
.lockup{position:absolute;left:clamp(30px,5.5vw,110px);right:clamp(30px,5.5vw,110px);top:50%;transform:translateY(-50%);pointer-events:none}
.lockup .k{font-size:12px;letter-spacing:.24em;text-transform:uppercase;color:var(--dim);display:flex;align-items:center;gap:14px;margin-bottom:clamp(14px,1.6vw,26px);flex-wrap:wrap}
.lockup .k s{display:block;width:clamp(34px,4vw,70px);height:1px;background:var(--hair);text-decoration:none}
.lockup h1{font-family:var(--serif);font-size:clamp(58px,8.2vw,168px);line-height:.86;letter-spacing:-.024em}
.lockup h1 em{font-style:normal;color:var(--ember)}
.lockup p{font-family:var(--serif);font-style:italic;font-size:clamp(19px,1.9vw,34px);line-height:1.26;max-width:min(22em,44vw);margin-top:clamp(16px,2vw,36px);opacity:.92;text-wrap:pretty}
.lockup .fine{font-family:var(--sans);font-style:normal;font-size:13px;letter-spacing:.02em;line-height:1.55;color:var(--dim);max-width:min(44em,44vw);margin-top:14px}
.chapter{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;padding:0 clamp(30px,5.5vw,110px);opacity:0;visibility:hidden;pointer-events:none}
.chapter.right{align-items:flex-end;text-align:right}
.chapter > *{position:relative}
.num{display:flex;align-items:center;gap:14px;font-size:14px;letter-spacing:.26em;text-transform:uppercase;color:var(--dim);margin-bottom:clamp(10px,1.2vw,22px);text-shadow:0 0 10px rgba(10,8,6,.9),0 0 3px rgba(10,8,6,.85)}
.num s{display:block;width:clamp(34px,4vw,70px);height:1px;background:var(--hair);text-decoration:none}
.big{font-family:var(--serif);font-size:clamp(76px,14.2vw,280px);line-height:.79;letter-spacing:-.024em;display:flex;align-items:baseline;gap:clamp(14px,2vw,38px);flex-wrap:wrap}
@media (min-width:821px){
/* the kanji stand in a column at the outer edge, the way they would be written, so the title keeps clear of the work */
.big{flex-wrap:nowrap;align-items:flex-start}
.big em{writing-mode:vertical-rl;font-size:clamp(28px,4.2vw,82px);line-height:1;transform:translateY(.02em);letter-spacing:.06em}
}
.big em{font-family:var(--mincho);font-style:normal;font-size:clamp(32px,5.8vw,108px);letter-spacing:.10em;opacity:.93;line-height:1;transform:translateY(-.12em)}
.lede{font-family:var(--serif);font-style:italic;font-size:clamp(20px,2.35vw,42px);line-height:1.26;max-width:min(16.5em,44vw);margin-top:clamp(16px,1.8vw,34px);opacity:.9;text-wrap:pretty}
.meta{display:flex;gap:clamp(20px,3vw,56px);margin-top:clamp(18px,2.2vw,40px);font-size:13px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}
.chapter.right .meta{justify-content:flex-end}
.meta b{display:block;font-size:17px;letter-spacing:.01em;text-transform:none;color:var(--ink);margin-top:5px}
.scrim{position:absolute;inset:0;pointer-events:none;visibility:hidden;
background:radial-gradient(38% 24% at 30% 40%,rgba(10,8,6,.55),rgba(10,8,6,0)),linear-gradient(90deg,rgba(10,8,6,.78),rgba(10,8,6,.5) 22%,rgba(10,8,6,.14) 40%,rgba(10,8,6,0) 50%)}
.scrim.right{background:radial-gradient(38% 24% at 70% 40%,rgba(10,8,6,.55),rgba(10,8,6,0)),linear-gradient(270deg,rgba(10,8,6,.78),rgba(10,8,6,.5) 22%,rgba(10,8,6,.14) 40%,rgba(10,8,6,0) 50%)}
.index{position:absolute;right:30px;bottom:28px;display:grid;grid-template-columns:auto auto;column-gap:16px;text-align:right;list-style:none}
.index::before{content:"";position:absolute;z-index:-1;pointer-events:none;inset:-34px -30px -28px -60px;background:radial-gradient(closest-side,rgba(9,7,5,.72),rgba(9,7,5,.42) 60%,rgba(9,7,5,0))}
.index u{text-decoration:none;font-size:11.5px;opacity:.62;font-variant-numeric:tabular-nums;line-height:20px}
.index a{font-size:13.5px;line-height:20px;letter-spacing:.005em;color:inherit;text-decoration:none;transition:opacity .25s ease;opacity:.55}
.index a i{font-style:normal;font-family:var(--mincho);font-size:12.5px;opacity:.72;margin-left:8px;letter-spacing:.08em}
.index a:hover{opacity:1}
.index a[aria-current="step"],.index u.on{opacity:1}
.vol{position:absolute;left:30px;bottom:28px;font-size:11.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--dim);font-variant-numeric:tabular-nums;line-height:1.7}
.vol span{display:block;font-family:var(--mincho);letter-spacing:.16em;text-transform:none;font-size:12px;opacity:.72}
.cue{position:absolute;left:50%;bottom:26px;transform:translateX(-50%);font-size:11px;letter-spacing:.24em;text-transform:uppercase;text-align:center;white-space:nowrap;pointer-events:none}
.cue::after{content:"";display:block;width:1px;height:22px;margin:10px auto 0;background:linear-gradient(var(--hair),transparent)}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
@media (min-width:821px) and (max-height:800px){
.index a:not([aria-current="step"]),.index u:not(.on){display:none}
.index u.on::after{content:" / 05";opacity:.8}
}
@media (max-width:820px){
.bar{height:auto;padding:12px 20px;gap:12px;flex-wrap:wrap}
.bar .push{display:none}
.brand{font-size:15px}
.modes{margin-left:auto}
.modes span{padding:7px 12px}
.chapter{padding:0 20px 15vh;justify-content:flex-end}
.chapter.right{align-items:flex-start;text-align:left}
.chapter.right .meta{justify-content:flex-start}
.chapter.right .big{flex-direction:row-reverse;justify-content:flex-end}
.big{font-size:clamp(70px,22vw,120px)}
.lede{max-width:none;font-size:clamp(19px,5.2vw,26px)}
.meta{gap:20px}
.meta b{font-size:15px}
.art{width:74vw;top:11%;transform:none;left:50%!important;right:auto!important;margin-left:-37vw}
.lockup{top:auto;bottom:15vh;transform:none;left:20px;right:20px}
.lockup h1{font-size:clamp(46px,14.6vw,90px)}
.lockup p{max-width:none;margin-top:14px}
.lockup .fine{display:none}
.lockup .k{font-size:11px;letter-spacing:.14em;gap:10px}
.lockup .k s{display:none}
.lockup p{font-size:clamp(17px,4.6vw,22px)}
.index a:not([aria-current="step"]),.index u:not(.on){display:none}
.index u.on::after{content:" / 05";opacity:.8}
.index{right:20px;bottom:20px}
.index a{font-size:12px;line-height:15px}
.index u{line-height:15px;font-size:11px}
.index a i{display:inline}
.vol{left:20px;bottom:20px;font-size:11px}
.vol span{display:none}
.cue{display:none}
}
</style>
</head>
<body>
<div id="track"></div>
<div id="stage" aria-label="Five crafts of a Kyoto woodshop">
<div class="layer a" id="layerA"></div>
<div class="layer b" id="layerB" aria-hidden="true"></div>
<div class="fx" id="fx" aria-hidden="true">
<div class="veil" id="veil"></div>
<div class="glow" id="glow"></div>
<div class="flame" id="flame"></div>
</div>
<div id="smoke" aria-hidden="true"></div>
<div class="chrome" id="chrome">
<div class="bar" id="bar">
<a class="brand" href="#s1" data-go="0" aria-label="KIBORI, back to the first craft">KIBORI<i lang="ja">木彫</i></a>
<span class="push"></span>
<fieldset class="modes" id="modes">
<legend>Transition between scenes</legend>
<label><input type="radio" name="mode" value="burn" checked><span>Burn wipe</span></label>
<label><input type="radio" name="mode" value="pan"><span>Pan</span></label>
</fieldset>
</div>
<div class="scrim" id="scrim0" aria-hidden="true"></div>
<div class="scrim right" id="scrim1" aria-hidden="true"></div>
<header class="lockup" id="lockup">
<div class="k"><s></s>Vanilla JavaScript · DOM/CSS · scroll-derived state</div>
<h1>Horizontal<br><em>Scroll</em> Scenes</h1>
<p>Five crafts laid left to right on one stage. Vertical scroll is the only input: each screen of it is a place you can land, reverse through, skip across or reload into.</p>
<p class="fine">No WebGL, no GSAP, no wheel hijack, no scroll-snap. The page height is the timeline; one pure function turns scrollY into scene, dwell and move.</p>
</header>
<section class="chapter" id="ch0" aria-labelledby="t0">
<div class="num"><s></s>01 — Lattice</div>
<h2 class="big" id="t0">Kumiko<em lang="ja">組子</em></h2>
<div class="lede">Slender strips of hinoki, cut to the width of a fingernail and locked without a single nail.</div>
<div class="meta"><div>Pattern<b>Asanoha</b></div><div>Members<b>3,142</b></div><div>Tolerance<b>0.05 mm</b></div></div>
</section>
<section class="chapter right" id="ch1" aria-labelledby="t1">
<div class="num"><s></s>02 — Carved wave</div>
<h2 class="big" id="t1">Seigaiha<em lang="ja">青海波</em></h2>
<div class="lede">A single cedar board, and a gouge that never lifts.</div>
<div class="meta"><div>Stock<b>Yoshino cedar</b></div><div>Rows<b>6</b></div><div>Depth<b>3 mm</b></div></div>
</section>
<section class="chapter" id="ch2" aria-labelledby="t2">
<div class="num"><s></s>03 — Joinery</div>
<h2 class="big" id="t2">Kigumi</h2>
<div class="lede">The beam comes down on the post and the peg goes through. No glue, no screws.</div>
<div class="meta"><div>Joint<b>Hozo-sashi</b></div><div>Peg<b>Oak</b></div><div>Fit<b>Hand-driven</b></div></div>
</section>
<section class="chapter right" id="ch3" aria-labelledby="t3">
<div class="num"><s></s>04 — Charred cedar</div>
<h2 class="big" id="t3">Yakisugi<em lang="ja">焼杉</em></h2>
<div class="lede">Fire takes the surface so that weather never can.</div>
<div class="meta"><div>Burn<b>Three-board flue</b></div><div>Char<b>1.2 mm</b></div><div>Finish<b>Brushed</b></div></div>
</section>
<section class="chapter" id="ch4" aria-labelledby="t4">
<div class="num"><s></s>05 — Lacquer</div>
<h2 class="big" id="t4">Urushi<em lang="ja">漆</em></h2>
<div class="lede">Coat after coat, each cured in damp air and rubbed back.</div>
<div class="meta"><div>Coats<b>Three</b></div><div>Cure<b>24 h each</b></div><div>Base<b>Hinoki</b></div></div>
</section>
<div class="vol" id="vol" aria-hidden="true">u 0.00 / 9.15<span id="volsub">木彫工房</span></div>
<nav class="index" id="index" aria-label="Scenes"></nav>
<div class="cue" id="cue" aria-hidden="true">Scroll</div>
<div class="sr" id="live" role="status" aria-live="polite"></div>
</div>
</div>
<script>
(() => {
'use strict';
/* ================================================================
* 1. The pace. Every number of the piece lives in these three weights
* and the track height (span + 1 screens).
* ================================================================ */
const N = 5;
const DWELL = 1.15, MOVE = 0.85; // screens of scroll per scene / per move
const CYC = DWELL + MOVE;
const SPAN = N*DWELL + (N - 1)*MOVE; // 9.15; the last scene is all dwell
const LK0 = 0.02, LK1 = 0.40; // the title's own burn-off, inside dwell 0
const clamp01 = x => x < 0 ? 0 : (x > 1 ? 1 : x);
const sstep = (a, b, x) => { const t = clamp01((x - a)/(b - a)); return t*t*(3 - 2*t); };
/* ================================================================
* 2. THE state machine: scroll progress in, scene state out. Pure.
* ================================================================ */
function travel(sp){
const u = Math.min(SPAN - 1e-5, sp*SPAN);
const i = Math.min(N - 1, Math.floor(u/CYC));
const local = u - i*CYC;
const dw = Math.min(1, local/DWELL); // the scene's own clock
const f = i >= N - 1 ? 0 : clamp01((local - DWELL)/MOVE); // the move
const rc = clamp01((f - 0.01)/0.97); // the line's own clock
const L = -0.08 + 1.24*rc; // line, in sweep coordinates; off-frame at both ends
return {i, u, dw, f, fe:sstep(0, 1, f), L, live: Math.min(N - 1, f < 0.39 ? i : i + 1)};
}
/* ================================================================
* 3. DOM: the world is built twice (A: the scene being left, frozen; B: the
* live one behind the line). Scenes read their craft clock from CSS vars
* on the stage, so both copies advance from one write per frame.
* ================================================================ */
const STAGE = document.getElementById('stage');
const track = document.getElementById('track');
const layerA = document.getElementById('layerA'), layerB = document.getElementById('layerB');
const fx = document.getElementById('fx'), veilEl = document.getElementById('veil'), glowEl = document.getElementById('glow'), flameEl = document.getElementById('flame');
const smoke = document.getElementById('smoke');
const lockup = document.getElementById('lockup');
const chapters = Array.from(document.querySelectorAll('.chapter'));
const scrims = [document.getElementById('scrim0'), document.getElementById('scrim1')];
const cue = document.getElementById('cue'), vol = document.getElementById('vol'), volsub = document.getElementById('volsub');
const live = document.getElementById('live');
const RIGHT = chapters.map(c => c.classList.contains('right'));
/* ---- scene art: inline SVG, one craft each, driven by --p{k} (linear dwell) and --e{k} (eased) ---- */
function art(k, uid){
const P = (n, d) => `--k:${d}`;
if (k === 0){ // lattice: strips drawn in row by row
let s = `<svg viewBox="0 0 640 480" role="img" aria-label="A kumiko lattice panel being assembled"><rect x="8" y="8" width="624" height="464" fill="#d9c398" stroke="#8a5a2b" stroke-width="16"/><rect x="24" y="24" width="592" height="432" fill="#efe1bb"/>`;
s += `<g fill="none" stroke="#a8763a" stroke-width="5" stroke-linecap="round">`;
const ln = (x1,y1,x2,y2,d) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" pathLength="1" style="--k:${d.toFixed(3)}" class="st0"/>`;
/* the bare skeleton is there at the opening; the diagonals are fitted over the dwell */
for (let r = 1; r < 6; r++) s += ln(24, 24 + r*72, 616, 24 + r*72, -1);
for (let c = 1; c < 8; c++) s += ln(24 + c*74, 24, 24 + c*74, 456, -1);
for (let r = 0; r < 6; r++) for (let c = 0; c < 8; c++){
const x = 24 + c*74, y = 24 + r*72, d = 0.02 + (r + c)*0.075;
s += ln(x, y, x + 74, y + 72, d) + ln(x + 74, y, x, y + 72, d + 0.01);
}
return s + `</g></svg>`;
}
if (k === 1){ // seigaiha: rows of concentric arcs, carved in left to right
let s = `<svg viewBox="0 0 640 480" role="img" aria-label="A cedar board carved with a wave pattern"><rect x="8" y="8" width="624" height="464" fill="#c9a06a" stroke="#7a4b22" stroke-width="16"/><g fill="none" stroke="#5b3517" stroke-width="4" stroke-linecap="round">`;
const R = [46, 34, 22, 10];
for (let r = 0; r < 6; r++) for (let c = 0; c < 8; c++){
const cx = 24 + 46 + c*92 + (r % 2 ? 46 : 0), cy = 470 - r*68 - 40 + 40;
if (cx - 46 < 20 || cx + 46 > 622) continue;
R.forEach((rad, j) => { s += `<path d="M${cx - rad} ${cy - 20} A${rad} ${rad} 0 0 1 ${cx + rad} ${cy - 20}" pathLength="1" class="st1" style="--k:${(c*0.045 + (5 - r)*0.02 + j*0.012).toFixed(3)}"/>`; });
}
return s + `</g></svg>`;
}
if (k === 2){ // joinery: post, beam lowered onto the tenon, peg
return `<svg viewBox="0 0 640 480" role="img" aria-label="A beam lowered onto a post and pegged"><rect x="270" y="150" width="100" height="320" fill="#b8874d" stroke="#6b421c" stroke-width="6"/><rect x="292" y="126" width="56" height="30" fill="#c99a5f" stroke="#6b421c" stroke-width="4"/><g class="beam2"><rect x="30" y="86" width="580" height="88" fill="#c99a5f" stroke="#6b421c" stroke-width="6"/><rect x="288" y="150" width="64" height="26" fill="#0c0906"/><g stroke="#6b421c" stroke-width="2" opacity=".55"><line x1="40" y1="112" x2="600" y2="112"/><line x1="40" y1="138" x2="600" y2="138"/></g></g><rect class="peg2" x="309" y="236" width="22" height="70" rx="6" fill="#4b2c14"/><rect x="60" y="470" width="520" height="8" fill="#000" opacity=".35"/></svg>`;
}
if (k === 3){ // yakisugi: planks, char sweeping down them
let s = `<svg viewBox="0 0 640 480" role="img" aria-label="Cedar planks being charred"><defs><clipPath id="cl${uid}"><rect x="8" y="8" width="624" height="464"/></clipPath></defs><g clip-path="url(#cl${uid})">`;
for (let c = 0; c < 6; c++) s += `<rect x="${8 + c*104}" y="8" width="104" height="464" fill="${['#c9a06a','#c19662','#cfa672','#c39a66','#c9a06a','#bf9260'][c]}" stroke="#7a4b22" stroke-width="3"/>`;
s += `<g class="char3">`;
for (let c = 0; c < 6; c++) s += `<rect x="${8 + c*104}" y="8" width="104" height="464" fill="#17100a"/><path d="M${20 + c*104} 8 v464 M${44 + c*104} 8 v464 M${68 + c*104} 8 v464 M${92 + c*104} 8 v464" stroke="#2c1d10" stroke-width="3" fill="none"/>`;
s += `</g></g><rect x="8" y="8" width="624" height="464" fill="none" stroke="#7a4b22" stroke-width="16"/></svg>`;
return s;
}
return `<svg viewBox="0 0 640 480" role="img" aria-label="A hinoki bowl taking coats of lacquer"><path d="M60 170 H580 C580 330 470 430 320 430 C170 430 60 330 60 170Z" fill="#d9b884" stroke="#8a5a2b" stroke-width="8"/><path class="coat4" d="M60 170 H580 C580 330 470 430 320 430 C170 430 60 330 60 170Z" fill="#7a1712"/><ellipse cx="320" cy="170" rx="260" ry="46" fill="#3a1a12" stroke="#8a5a2b" stroke-width="8"/><ellipse class="coat4" cx="320" cy="170" rx="238" ry="36" fill="#0a0806"/><rect x="140" y="440" width="360" height="8" fill="#000" opacity=".35"/></svg>`;
}
function buildWorld(host, uid){
const world = document.createElement('div');
world.className = 'world';
for (let k = 0; k < N; k++){
const sc = document.createElement('div');
sc.className = 'scene'; sc.style.setProperty('--i', k);
sc.style.setProperty('--wx', (RIGHT[k] ? 72 : 28) + '%');
sc.innerHTML = `<div class="bg"></div><div class="window"></div><div class="art ${RIGHT[k] ? 'l' : 'r'}">${art(k, uid)}</div>`;
world.appendChild(sc);
}
host.appendChild(world);
return world;
}
const worldA = buildWorld(layerA, 'a'), worldB = buildWorld(layerB, 'b');
/* craft rules, written once as CSS on the two copies */
const craft = document.createElement('style');
craft.textContent = `
.st0{stroke-dasharray:1;stroke-dashoffset:calc(1 - clamp(0, (var(--e0) * 1.35 - var(--k)) * 5, 1))}
.st1{stroke-dasharray:1;stroke-dashoffset:calc(1 - clamp(0, (var(--e1) * 1.5 - var(--k)) * 5, 1))}
.beam2{transform:translateY(calc((1 - var(--e2)) * -190px))}
.peg2{opacity:clamp(0, (var(--p2) - .72) * 6, 1);transform:translateY(calc((1 - clamp(0, (var(--p2) - .72) * 4, 1)) * -30px))}
.char3{clip-path:inset(0 0 calc((1 - var(--e3)) * 100%) 0)}
.coat4{opacity:var(--e4)}
`;
document.head.appendChild(craft);
/* ---- the ragged flame edge, built once ---- */
{
let l = [], r = [], seed = 7;
const rnd = () => (seed = (seed*1664525 + 1013904223) >>> 0) / 4294967296;
for (let y = 0; y <= 100; y += 2){ l.push(`${(24 + rnd()*16).toFixed(1)}% ${y}%`); r.push(`${(60 + rnd()*16).toFixed(1)}% ${100 - y}%`); }
flameEl.style.clipPath = `polygon(${l.join(',')},${r.join(',')})`;
}
/* ---- index ---- */
const NAMES = [['01','Kumiko','組子'],['02','Seigaiha','青海波'],['03','Kigumi','木組'],['04','Yakisugi','焼杉'],['05','Urushi','漆']];
const indexEl = document.getElementById('index');
indexEl.innerHTML = NAMES.map(([n, r, kj], i) =>
`<u aria-hidden="true">${n}</u><a href="#s${i+1}" data-go="${i}"><span class="sr">Scene ${i+1}, </span>${r}${kj ? `<i lang="ja" aria-hidden="true">${kj}</i>` : ''}</a>`).join('');
const idxLinks = Array.from(indexEl.querySelectorAll('a')), idxNums = Array.from(indexEl.querySelectorAll('u'));
/* ================================================================
* 4. Render: reads scrollY, writes styles. No state kept between frames
* except what glide and the mode control own.
* ================================================================ */
const S = {reduced: matchMedia('(prefers-reduced-motion: reduce)').matches, mode: 'burn', glideTo: -1, glideHide: -1, veil: 0, w: 0, h: 0, announced: -1};
matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', e => { S.reduced = e.matches; dirty(); });
const cache = new WeakMap();
const setCss = (el, k, v) => { let c = cache.get(el); if (!c) cache.set(el, c = {}); if (c[k] !== v){ c[k] = v; el.style[k] = v; } };
const setVar = (el, k, v) => { let c = cache.get(el); if (!c) cache.set(el, c = {}); if (c[k] !== v){ c[k] = v; el.style.setProperty(k, v); } };
function scrollProgress(){
const max = document.documentElement.scrollHeight - innerHeight;
return max > 0 ? clamp01(scrollY/max) : 0;
}
function announce(i){
if (S.announced === i) return;
S.announced = i;
live.textContent = `Scene ${i + 1} of ${N}: ${NAMES[i][1]}`;
}
function render(){
raf = 0;
if (!S.w || !S.h) return; // zero viewport: nothing to lay out yet
const sp = scrollProgress(), T = travel(sp);
const {i, u, f, dw} = T;
const burn = !S.reduced && S.mode === 'burn';
const burning = burn && f > 0 && f < 1;
const pro = burn && i === 0 && u > LK0 && u < LK1; // the title's line
const dir = pro ? 1 : (RIGHT[Math.min(N - 1, i + 1)] ? -1 : 1);
const Lb = burning ? T.L : (pro ? -0.08 + 1.24*clamp01((u - LK0)/(LK1 - LK0)) : -1);
/* craft clocks: 1 behind you, 0 ahead, the dwell clock at the scene you are in */
const st = STAGE;
for (let k = 0; k < N; k++){
const p = k < i ? 1 : (k === i ? dw : 0);
setVar(st, '--p' + k, p.toFixed(4));
setVar(st, '--e' + k, sstep(0, 1, p).toFixed(4));
}
/* camera */
const pct = c => `translate3d(${(-c/N*100).toFixed(4)}%,0,0)`;
if (S.mode === 'pan' && !S.reduced){
setCss(worldA, 'transform', pct(i + T.fe + 0.02*(dw - 0.5)*(f > 0 ? 0 : 1)));
setCss(layerB, 'visibility', 'hidden'); setCss(layerA, 'clipPath', 'none'); setCss(fx, 'visibility', 'hidden');
} else if (S.reduced){
// designed still: no travel, a plain cross-fade between two fixed frames
setCss(worldA, 'transform', pct(i));
setCss(worldB, 'transform', pct(Math.min(N - 1, i + 1)));
setCss(layerA, 'clipPath', 'none'); setCss(fx, 'visibility', 'hidden');
setCss(layerB, 'visibility', f > 0 ? 'visible' : 'hidden');
setCss(layerB, 'opacity', sstep(0.30, 0.70, f).toFixed(3));
} else {
setCss(layerB, 'opacity', '1');
setCss(worldA, 'transform', pct(i));
setCss(worldB, 'transform', pct(pro ? 0 : Math.min(N - 1, i + 1)));
const xl = dir > 0 ? Lb : 1 - Lb; // line, as a fraction from the left
if (burning || pro){
const cut = clamp01(xl)*100;
if (burning){
/* A ahead of the line, B behind it */
setCss(layerA, 'clipPath', dir > 0 ? `inset(0 0 0 ${cut.toFixed(2)}%)` : `inset(0 ${(100 - cut).toFixed(2)}% 0 0)`);
setCss(layerB, 'clipPath', dir > 0 ? `inset(0 ${(100 - cut).toFixed(2)}% 0 0)` : `inset(0 0 0 ${cut.toFixed(2)}%)`);
setCss(layerB, 'visibility', 'visible');
} else { setCss(layerA, 'clipPath', 'none'); setCss(layerB, 'visibility', 'hidden'); }
const x = xl*S.w;
setCss(fx, 'visibility', Lb > -0.06 && Lb < 1.12 ? 'visible' : 'hidden');
setCss(flameEl, 'transform', `translate3d(${x.toFixed(1)}px,0,0)`);
setCss(glowEl, 'transform', `translate3d(${x.toFixed(1)}px,0,0)`);
flameEl.style.left = glowEl.style.left = '0px';
veilEl.classList.toggle('rev', dir < 0);
const vw = S.w*0.34;
setCss(veilEl, 'transform', `translate3d(${(dir > 0 ? x : x - vw).toFixed(1)}px,0,0)`);
setCss(veilEl, 'left', '0px');
} else {
setCss(layerA, 'clipPath', 'none'); setCss(layerB, 'visibility', 'hidden'); setCss(fx, 'visibility', 'hidden');
}
}
/* chrome */
const introA = 1;
setCss(cue, 'opacity', (0.55*(1 - Math.min(1, sp/0.018))).toFixed(3));
const liveIdx = S.glideTo >= 0 ? S.glideTo : T.live;
idxLinks.forEach((a, k) => {
const on = k === liveIdx;
if (on) a.setAttribute('aria-current', 'step'); else a.removeAttribute('aria-current');
idxNums[k].classList.toggle('on', on);
});
if (S.glideTo < 0) announce(T.live);
vol.firstChild.nodeValue = `u ${u.toFixed(2).padStart(4, '0')} / ${SPAN.toFixed(2)} · S${i + 1}`;
volsub.textContent = f > 0 ? `move ${f.toFixed(2)}` : `dwell ${dw.toFixed(2)}`;
/* the title */
let lk = 1 - sstep(LK0, LK1 - 0.05, u);
if (S.reduced) lk = 1 - sstep(0.02, 0.22, u);
if (i > 0) lk = 0;
let lmask = '';
if (pro){ const L = Lb; lmask = `linear-gradient(90deg,transparent ${((L - 0.01)*100).toFixed(2)}%,#000 ${((L + 0.016)*100).toFixed(2)}%)`; lk = 1; }
setCss(lockup, 'opacity', lk.toFixed(3));
setCss(lockup, 'visibility', lk < 0.004 ? 'hidden' : 'visible');
setCss(lockup, 'webkitMaskImage', lmask); setCss(lockup, 'maskImage', lmask);
lockup.inert = lk < 0.004;
/* chapters: each belongs to its station; the line writes the next one in behind it */
const ARR_IN = MOVE*0.80, ARR_OUT = DWELL*0.62;
const gAng = dir > 0 ? 90 : 270;
const mobile = S.w <= 820;
const show = [];
chapters.forEach((el, ci) => {
const home = ci*CYC;
let arrive = clamp01((u - (home - ARR_IN))/(ARR_IN + ARR_OUT));
if (ci === 0) arrive = S.reduced ? sstep(0.10, 0.80, u/DWELL) : (u <= LK0 ? 0 : Math.max(0.01, sstep(0.02, 0.60, u/DWELL)));
if (S.reduced && ci === i + 1 && f < 0.64) arrive = 0;
let leave = clamp01((u - (home + DWELL + MOVE*0.28))/(MOVE*0.34));
let mask = '';
if (burning && ci === i){
leave = Lb > 1.07 ? 1 : 0;
mask = `linear-gradient(${gAng}deg,transparent ${((Lb - 0.010)*100).toFixed(2)}%,#000 ${((Lb + 0.016)*100).toFixed(2)}%)`;
} else if ((burning && ci === i + 1 && !mobile) || (pro && ci === 0)){
const L = pro ? Lb : Lb;
if (L < 1.16) mask = `linear-gradient(${gAng}deg,#000 ${((L - 0.062)*100).toFixed(2)}%,transparent ${((L - 0.048)*100).toFixed(2)}%)`;
}
let shown = 1 - leave;
if (burning && ci === i + 1 && mobile){ shown *= sstep(1.00, 1.14, Lb); }
if (ci === S.glideHide) shown = 0;
if (S.glideTo >= 0 && ci !== S.glideTo && ci === i && f < 0.001) shown *= 1 - S.veil;
const hidden = arrive < 0.002 || shown < 0.004;
setCss(el, 'visibility', hidden ? 'hidden' : 'visible');
el.inert = hidden;
if (hidden) return;
show.push(ci);
setCss(el, 'webkitMaskImage', mask); setCss(el, 'maskImage', mask);
setCss(el, 'opacity', (introA*shown).toFixed(3));
/* the eyebrow, the title, the lede, the numbers: each on its own slice of one clock */
const parts = [el.querySelector('.num'), el.querySelector('.big'), el.querySelector('.lede'), el.querySelector('.meta')];
const A0 = [0, 0.12, 0.40, 0.60];
parts.forEach((p, k) => {
const o = burning && ci === i + 1 ? 1 : sstep(A0[k], A0[k] + 0.30, arrive);
setCss(p, 'opacity', o.toFixed(3));
setCss(p, 'transform', `translate3d(0,${((1 - o)*14).toFixed(1)}px,0)`);
});
});
scrims.forEach((sc, k) => { const on = show.some(ci => ci % 2 === k && !mobile); setCss(sc, 'visibility', on ? 'visible' : 'hidden'); });
}
let raf = 0;
const dirty = () => { if (!raf) raf = requestAnimationFrame(render); };
addEventListener('scroll', dirty, {passive: true});
addEventListener('pageshow', dirty);
addEventListener('load', dirty);
document.addEventListener('visibilitychange', () => { if (document.hidden) settleGlide(); dirty(); });
if (document.fonts && document.fonts.ready) document.fonts.ready.then(dirty);
const ro = new ResizeObserver(() => {
S.w = STAGE.clientWidth; S.h = STAGE.clientHeight; dirty();
});
ro.observe(STAGE);
document.querySelectorAll('input[name="mode"]').forEach(r => r.addEventListener('change', () => { S.mode = r.value; dirty(); }));
/* ================================================================
* 5. Fast-skip: the glide is ours. More than one scene away plays only
* the last move, under smoke, so a long jump is never a strobe of fires.
* ================================================================ */
let glide = null, glideDone = null;
const yFor = u => (document.documentElement.scrollHeight - innerHeight)*u/SPAN;
const setVeil = o => { S.veil = o; smoke.style.opacity = o.toFixed(3); smoke.style.visibility = o > 0.002 ? 'visible' : 'hidden'; };
function stopGlide(){ S.glideTo = -1; S.glideHide = -1; glideDone = null; if (glide){ cancelAnimationFrame(glide.raf); glide = null; } setVeil(0); dirty(); }
function settleGlide(){ if (glide){ const d = glideDone, y = glide.y; cancelAnimationFrame(glide.raf); glide = null; scrollTo(0, y); S.glideTo = -1; S.glideHide = -1; setVeil(0); if (d) d(); } }
const ease = x => x < 0.5 ? 4*x*x*x : 1 - Math.pow(-2*x + 2, 3)/2;
function leg(y, dur, veilAt, done, easeFn = ease){
const y0 = scrollY, dy = y - y0, t0 = performance.now();
glide = {raf: 0, y};
const step = now => {
const k = Math.min(1, (now - t0)/dur);
scrollTo(0, y0 + dy*easeFn(k));
if (veilAt) setVeil(veilAt(k));
if (k < 1) glide.raf = requestAnimationFrame(step);
else { glide = null; const d = done; done = null; d && d(); }
};
glide.raf = requestAnimationFrame(step);
}
function glideTo(y, done){
stopGlide();
const dy = y - scrollY;
if (S.reduced || Math.abs(dy) < 2){ scrollTo(0, y); done && done(); return; }
leg(y, Math.min(2400, Math.max(900, 700 + Math.abs(dy)/innerHeight*150)), null, done);
}
function glideToScene(tgt){
const cur = travel(scrollProgress()).i;
const yT = yFor(tgt*CYC + DWELL*0.80); // land with its craft 80% through
const fin = () => { S.glideTo = -1; S.glideHide = -1; S.announced = -1; dirty(); announce(tgt); };
if (S.reduced || Math.abs(tgt - cur) < 2){ S.glideTo = tgt; return glideTo(yT, fin); }
stopGlide();
const fwd = tgt > cur;
const yPre = yFor(fwd ? (tgt - 1)*CYC + DWELL*0.96 : (tgt + 1)*CYC + 0.02);
S.glideHide = fwd ? tgt - 1 : tgt + 1; S.glideTo = tgt; // the scene it lands just short of is only passed through
glideDone = fin;
const t0 = performance.now();
glide = {raf: 0, y: yT};
const cover = now => {
const k = Math.min(1, (now - t0)/380);
setVeil(sstep(0, 1, k));
if (k < 1){ glide.raf = requestAnimationFrame(cover); return; }
scrollTo(0, yPre);
glide.raf = requestAnimationFrame(() => { glide.raf = requestAnimationFrame(() => {
leg(yT, 2600, k2 => 1 - sstep(0.0, 0.24, k2), fin, x => 1 - Math.pow(1 - x, 2.4));
}); });
};
glide.raf = requestAnimationFrame(cover);
}
addEventListener('wheel', () => { if (glide || S.glideTo >= 0) stopGlide(); }, {passive: true});
addEventListener('touchstart', () => { if (glide || S.glideTo >= 0) stopGlide(); }, {passive: true});
addEventListener('keydown', e => { if (/Arrow|Page|Home|End|Space/.test(e.code) && (glide || S.glideTo >= 0)) stopGlide(); });
document.addEventListener('click', e => {
const a = e.target.closest && e.target.closest('[data-go]');
if (!a) return;
e.preventDefault();
glideToScene(+a.dataset.go);
});
/* debug handle for the verify harness */
window.__hs = {travel, SPAN, DWELL, MOVE, CYC, yFor, state(){ const T = travel(scrollProgress()); return {i:T.i, u:+T.u.toFixed(3), f:+T.f.toFixed(3), dw:+T.dw.toFixed(3), live:T.live}; }, render};
S.w = STAGE.clientWidth; S.h = STAGE.clientHeight;
render();
})();
</script>
</body>
</html>
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Meng To
- Publisher
- Meng To (Community)
- Source
- MengTo/Skills · agent-skills/web-design/horizontal-scroll-scenes · commit 5ea1661
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Meng To or Meng To.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download horizontal-scroll-scenes.zip