scrollcraft

Premium scroll-driven landing pages; scroll = timeline.

  • web-development
  • landing-page
  • scrollytelling
  • animation
  • design
  • frontend

Declared platforms: linux · macos · windows

Install
npx skills add 'https://github.com/NousResearch/hermes-agent/tree/main/optional-skills/web-development/scrollcraft'
Download bundle ↓
main · 24fd22bScanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md

Worldflight: the continuous-world page mode

Act mode cuts the page into pinned blocks. That is the right shape for a page of chapters and the wrong shape for one unbroken camera move, and building a continuous world out of acts produces exactly the page an owner described as "awful": you scroll down, the stage unsticks, a static page slides past, clean horizontal edges travel up the screen, and then you start scrolling down again. Every one of those defects is the same defect. A pinned act is a block in the document, and a document made of blocks has seams.

Worldflight removes the seams by removing the blocks.

There is one position: fixed stage for the whole page. Every leg of the flight is mounted in it at once and stays mounted. The only element in document flow is an empty spacer. Scroll drives two things and nothing else: the film timeline and the opacity of the overlay. Nothing travels, nothing pins, nothing unpins, and there is no boundary anywhere for a seam to show at.


1. The markup

<div data-sc-mode="worldflight" data-sc-seam="0.12">

  <div data-sc-world>
    <div data-sc-segment data-sc-w="0.95" data-sc-linger="0.3"
         data-sc-waypoint="Surface">
      <img class="sc-world__poster" src="assets/p1.webp" alt="" decoding="async">
      <video data-sc-src="assets/leg1.mp4"
             data-sc-src-mobile="assets/leg1-m.mp4"></video>
    </div>
    <div data-sc-segment data-sc-w="0.9" data-sc-linger="0.42"
         data-sc-waypoint="Thermocline">
      <img class="sc-world__poster" src="assets/p2.webp" alt="" decoding="async">
      <video data-sc-src="assets/leg2.mp4"
             data-sc-src-mobile="assets/leg2-m.mp4"></video>
    </div>
    <!-- legs in flight order, as many as the world has -->
  </div>

  <div data-sc-world-copy>
    <div class="sc-world__scrim sc-scrim sc-scrim--band"></div>
    <div class="sc-copy sc-copy--lead" data-sc-copy data-sc-window="hero"> … </div>
    <div class="sc-copy sc-copy--trail" data-sc-copy data-sc-window="0.38 0.66"> … </div>
    <div class="sc-copy sc-copy--lead" data-sc-copy data-sc-window="finale"> … </div>
  </div>

  <div data-sc-spacer aria-hidden="true"></div>
</div>

ScrollCraft.mount(document) as usual. The mode composes with nothing else on the page: a worldflight page has no acts.

Attributes

AttributeOnDefaultWhat it does
data-sc-mode="worldflight"mode rootn/aTurns the page into one flight.
data-sc-seammode root0.12Crossfade band, in viewport-heights of scroll. Clamped 0.02 to 0.4.
data-sc-worldstagen/aThe single fixed stage. Gets .sc-world.
data-sc-segmentlegn/aOne leg. Holds a poster and a clip.
data-sc-wleg1.3Scroll this leg owns, in viewport-heights.
data-sc-lingerleg0Dwell remap for this leg only. Clamped to 0.6.
data-sc-waypointlegn/aLabel published on the waypoint event.
data-sc-world-copycopy layern/aFixed overlay. Gets .sc-world__copy.
data-sc-copycopy blockn/aA windowed block of type.
data-sc-windowcopy blockn/ahero | finale | from to [in [out]].
data-sc-spacerspacern/aThe scroll track. Engine sets its height.
data-sc-lerproot or <video>0.18Playhead smoothing. See devices.md.

The engine generates no DOM here, the same as in act mode. It sets the spacer's height, and it writes opacity, visibility and z-index on the legs. Everything else is markup you wrote.


2. The scroll track

The spacer's height is (sum of the leg weights + 1) viewport-heights, set in pixels so it and the stage are measured on the same ruler (the stage is sized in svh, and on a phone vh and svh are different numbers).

The + 1 is not padding. Without it the track ends at the exact scroll position where the last leg reaches progress 1, so the final second of the last clip is a place the reader can never come to rest. One extra viewport gives the last flight room to land.

Position along the track, t, is measured in viewport-heights, which is the same unit the weights are written in. Leg i owns [c_i, c_i + w_i), and its local progress is (t - c_i) / w_i, remapped through lingerEase.


3. The seam

Two things make a boundary between clips invisible, and both are required.

The assets have to match at the seam (section 6). The engine cannot fix a mismatched cut.

The crossfade has to be one-sided. Over the seam band the incoming leg fades up from 0 to 1 while the outgoing leg holds at full strength underneath it, and the outgoing leg only drops to zero once it is completely covered. Fading both sides at once puts the page ground through the middle of every seam, which reads as a flash, and it is the obvious implementation. z-index favours the current leg (120) over the rest (100 + opacity × 10).

Each side of the band is half a seam width, so each leg holds its seam frame for about 0.06vh of scroll. Those are exactly the frames the seam law matched, so a held frame there is invisible by construction.

Nothing ever swaps a src. A src swap is a black frame, and a black frame is the cut this mode exists to remove.


4. The copy contract

Copy lives in one fixed layer above the stage. Each block declares a window against the whole track, not against a leg.

  • data-sc-window="hero": present from the first pixel, fades out by 0.62 of the first leg. A hero that fades IN has to fade in over an empty first screen, which is the one moment on the page with nothing else to look at.
  • data-sc-window="finale": fades in from 0.4 of the last leg and holds to the end.
  • data-sc-window="0.38 0.66": a plateau window across those track fractions: ramps in over the first 30%, holds at full opacity, ramps out over the last 30%. Add a third and fourth number to set the ramps yourself. The plateau is not decoration: a pure triangle touches opacity 1 for one instant, so the reader has to stop on exactly the right pixel to see the line at full strength and every heading reads slightly faded.

The only transform on the copy side is translateY, and it is capped at 4vh across the whole window (from +2vh to -2vh). Anything larger stops reading as a layer over a moving world and starts reading as a second page scrolling at a different speed, which is the cheapness this mode replaces. Pointer events are handed back to a block only above opacity 0.5.

.sc-world__scrim is provided for a scrim div on the copy side. Shape it to where the copy actually sits. The stock .sc-scrim--band tops out at 58% of the frame, and footage that stays bright past that will fail the contrast pass even though the page looks fine.


5. The route rail

The engine publishes the current leg index as --sc-seg and its local progress as --sc-segp, on the mode root and on :root, and fires a sc:waypoint CustomEvent (bubbling, detail: { index, count, label, el, progress }) whenever the leg changes.

It renders no rail. A gauge, a map, a depth readout, a leg counter and a set of chapter dots are all the same two numbers, and a runtime that ships one of them ships it to every page that uses this mode. Build the rail in the page:

addEventListener('sc:waypoint', (e) => {
  document.querySelectorAll('.rail__leg').forEach((el) => {
    el.setAttribute('aria-current', String(+el.dataset.leg === e.detail.index));
  });
});

6. The seam law for assets

A worldflight is only as good as the joins between its clips. Two architectures work; nothing else does.

Architecture A (preferred): chain on start images only. Each leg is generated from a start image and left to end wherever it ends. The next leg's start image is a frame pulled from the previous leg's encoded mp4. Never force an end-image wide shot: an image-to-image model asked to hit both ends resolves the conflict by pulling the camera back, and every leg ends up as the same wide establishing shot.

Architecture B: connector legs. Where two existing clips have to meet, cut a short connector whose start frame comes from the previous leg and whose end frame is the next leg's actual first frame.

Extract from the ENCODED mp4, not the source render. The encode changes the pixels, and a poster or a chain frame taken from the pre-encode master does not match the frame the browser will actually decode:

# last frame of the previous leg, as the next leg's start image
ffmpeg -sseof -0.15 -i legN.mp4 -frames:v 1 -q:v 2 chainN.png
# first frame of a leg, for its poster
ffmpeg -i legN.mp4 -frames:v 1 -q:v 3 pN.webp

Encoding

Same rules as any scrub clip, and they matter more here because a worldflight has more of them mounted at once.

  • GOP 8 desktop, GOP 4 mobile. Scrubbing is random access; a long GOP means every seek decodes a run of frames and the playhead lags behind the hand.
  • Ship data-sc-src-mobile for every leg. The engine picks it on coarse pointers and narrow viewports.
  • Posters as WebP, extracted as above.

7. Loading

A leg is fetched only while the reader is within ±1.6vh of it. Loading the whole flight up front is tens of megabytes before the first frame paints; loading on arrival means arriving at a poster.

Until a leg's first real frame has painted, its poster carries the move with a push-in (scale(1.03 + local × 0.14)). A still that sits perfectly still while the page scrolls announces itself as a placeholder; a slow push reads as the camera already flying.

Under reduced motion no clip is ever fetched. The posters are the film, they cross-dissolve through exactly the same seams at exactly the same scroll positions, the same copy windows open and close, and every transform is dropped. The whole story still reads.


7b. The spacer is sized once, at mount

layout() writes the spacer height as (total + 1) * innerHeight. If innerHeight reports 0 at that moment the spacer is set to 0px, the page has no scroll track, and the flight never advances.

It fails silently and it looks like success. The engine mounted, every leg registered, the clips fetched and decoded, sc-has-clip is on the segments, and there is nothing in the console. The page is simply a still image that cannot be scrolled. Embedded preview panes and some early loads do exactly this.

Do not fix it in the engine. One resize makes it re-measure correctly, so send one from the page once the window and the fonts have settled:

function relayout() { dispatchEvent(new Event('resize')); }
addEventListener('load', relayout);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(relayout);

The fonts.ready half earns its place independently: a webfont swapping in changes the measured height of every copy block, and anything the page sized against those blocks (a scrim plate, a rail) is wrong until it re-measures.

Check it with one line, and check it before blaming anything else:

document.documentElement.scrollHeight   // must be ~(sum of weights + 1) * innerHeight

7c. Pace: one speed, and slower than you think

Two separate faults get described as "it doesn't feel smooth", and only one of them is smoothing.

Inconsistent pace is the worse one. Leg weight divided by clip length is how fast the world moves under the reader's hand. If that number varies from leg to leg, the world surges and drags for no reason the reader can see, and it reads as a fault in the page rather than as pacing. Give every leg with the same clip length the same weight, and give a longer clip a proportional one. On orrery that number varied by 36% across ten legs on the first cut, and the owner's word for it was "not smooth". Evened to a 6% spread, the same footage reads as one continuous move.

rate = weight / clip_seconds        // hold this within a few percent everywhere

Then slow it down. The instinct is to spend as little scroll as possible. A fly-through wants the opposite: the reader is steering a camera, and a camera that answers too eagerly feels twitchy. 0.21 to 0.22vh per second of film is a good floor for a world you fly through. 0.14 to 0.19, which is what the per-8s line yields, is noticeably fast.

That line is a dead-scroll guardrail, not a taste ceiling. Exceeding it is fine and often correct; exceeding it without checking is not. The harness defines dead scroll mechanically and will tell you. At 0.216vh/s a 0.12vh sample gap still advances the clip by half a second, nowhere near dead.

Damp the playhead and widen the joins. data-sc-lerp defaults to 0.18; 0.12 is the better default for a worldflight, because a flight has more legs mounted and more seams than an act page, and the extra damping is what actually removes wheel-event judder. Widen data-sc-seam from 0.12 to ~0.16 for the same reason: a longer crossfade band gives each join more room to disappear in.

Changing weights moves every leg boundary, so every data-sc-window has to be recomputed against the new track and then re-checked on screen. A copy window is tuned to a frame of film, not to a number.

8. Hard rules

RuleWhy
Nothing in document flow but the spacer.The moment a real block scrolls past the fixed stage, the page has a seam and the mode is pointless. If you want a section, you want act mode.
Copy translate ≤ 4vh across a window.Larger reads as a second page scrolling at a different speed.
The lerp is never disabled except under reduced motion.A 1:1 playhead reproduces every gap in the wheel event stream as a stutter.
One pace for the whole flight, and slower than feels necessary.Weight divided by clip length must match across legs, or the world surges and drags. ~1.5vh per 8s is the dead-scroll guardrail, not the target. See section 7c.
Every clip stays mounted. Never swap a src.A src swap is a black frame.
Seam frames come from the encoded mp4.The encode changes the pixels.
One accent, one scrim shape, copy anchored off the bright centre.Verified by the contrast pass, which grades copy blocks exactly like cues, at the worst frame each line is ever shown on.

9. Verifying

shoot.mjs detects [data-sc-mode="worldflight"] and switches modes. It samples across the spacer track at the same density it samples acts, plus four extra positions across every seam, and it waits for the lerp to settle before each shot (a screenshot taken mid-lerp is a frame the page never actually holds, and it makes the run unrepeatable).

It reports:

  • dead scroll, defined here as no leg advancing its currentTime, no crossfade progress, and no copy-window opacity change between two samples more than 0.12vh apart. Skipped under reduced motion, where each leg legitimately holds one still frame.
  • legs that never reach full opacity: a weight or a seam that is wrong: the reader is shown a permanent dissolve and never the leg itself.
  • legs stuck on poster: a clip that never loaded or never decoded. It passes every other check, because a poster looks exactly like a paused film.
  • contrast on visible copy blocks, through the same direction-aware compositing path as cues.
node scripts/serve.mjs --root builds/<name> --port 45XX
node scripts/shoot.mjs --url http://localhost:45XX --out lab/<name>-shots --per-act 8
node scripts/shoot.mjs --url http://localhost:45XX --out lab/<name>-reduced --reduced-motion

The mechanical assertions ship with the skill as scripts/worldflight-assert.mjs and run against any worldflight page, not a special rig: spacer height, fixed stage, nothing in document flow, lerp convergence and non-overshoot, seam monotonicity, the copy transform cap, and the reduced-motion contract.

node <skill>/scripts/worldflight-assert.mjs --url http://localhost:45XX

Run it against your own build before the contact sheet. It answers "does the mode actually hold" in a way a screenshot cannot.

Referenced from SKILL.md