Releases: danhnm1203/scrollytelling
Release list
v0.7.0 — a shared link shows the page, not a bare url
Paste the address of a page this tool built into Slack, X or Discord and you got
a bare link. A crawler never runs the page — it reads the served markup and stops
— so the head at build time is the whole preview, and only the Next template had
one. The demo, which every link in this README and in the package metadata points
at, was built with html: the one stack with no card at all.
All four templates now emit the same card, each through its own head mechanism,
from one shared source. The build refuses to publish a demo whose preview did not
come out, and the README finally shows what the tool makes.
Added
-
frames --site-url <url>, and a link preview built from it. A page cannot
work out its own address:document.baseURIanswers for a browser that has
already fetched it, and a crawler building a preview never runs the page at
all. Told where it will be served from,frameswrites a 1200×630og.jpg
cut from the frame the page opens on, and fills theog:andtwitter:tags
fromcomponents/story.js.The value keeps its path — a GitHub Pages project site is served from
/<repo>/, not from the origin root — and is normalised to end in a slash,
because url resolution drops the last segment without one. A relative url, a
scheme no crawler will fetch, and a base carrying a query are all refused with
the value quoted back.Without the flag the words still fill and the image and url stay empty. That is
deliberate: a relativeog:imageresolves against the crawler's own base and
silently fetches something else, which is worse than no card. -
The same card on Next, Nuxt, Astro and plain HTML. Each stack keeps the
head mechanism a reader of that stack expects —metadata,useHead, Astro
markup, and a pageframesrewrites — but what the card contains now comes
from one module installed into every project, the way the scroll math already
was. Four mechanisms is right; four answers would drift, and the drift is
invisible because every page still renders.Next stops using the opening webp frame as its preview image. The frames are
webp because the page decodes them and the page is a browser; a link unfurler
is somebody else's code, and webp support across that set is unverified.
Fixed
- A generated Next project carried one machine's TypeScript build cache.
templates/next/tsconfig.tsbuildinfois gitignored, butfilesin
package.json is an allowlist and takes precedence, sonpm packshipped it and
scaffoldcopied it into every project. A stale.tsbuildinfomakestsc
skip files it believes are unchanged, so a fresh project's type errors went
unreported — quiet and wrong rather than loud. Build output is now skipped when
a template is walked, for every framework rather than only the one that was
seen.
Upgrading
Nothing changes for a project that does not pass --site-url; the generated
contract and the page it produces are byte-identical to 0.6.1.
scaffold --diff will report index.html as changed in the template and
edited by you for every project generated before this. That report is accurate
— the template gained the card tags, and your page has your copy in it — but
adopting is the wrong move, because it would replace your copy with the
template's. Copy the og: and twitter: block out of a fresh scaffold into your
page's <head> instead, then re-run frames. See
docs/en/cli-reference.md.
v0.6.1 — the page stops asking for frames it already has
Fixed
-
"No frames yet" showed on pages that had frames. The
htmltemplate
styled its empty-state panel with[data-scrollytelling-empty] { display: grid }. That panel ships with thehiddenattribute and is un-hidden only
when there is no sequence — but an author rule with an attribute selector
outranks the browser's own[hidden] { display: none }, so every generated
page rendered a full extra screen reading "Generate a sequence to make this
page scroll" underneath a page that already scrolled.Nothing reported it.
element.hiddenstill returnedtrue, the console
stayed clean, and the canvas above it worked perfectly. The selector is now
guarded with:not([hidden]).If you generated an
htmlproject with 0.6.0, the fix is one line in
index.html: change[data-scrollytelling-empty]to
[data-scrollytelling-empty]:not([hidden]).
Added
homepagepoints at the live demo, on npm and in the plugin manifest, and
the README links it above the fold.
v0.6.0 — a page you can scroll, and it works where you put it
Four templates have shipped since 0.5.0, but everything describing this tool
still said Next.js, and there was nowhere to see what it produces. Both are
fixed, and building the demo turned up a bug that would have hit anyone
deploying the html template anywhere but a domain root.
Fixed
-
A page from the
htmltemplate 404'd every frame when deployed under a base
path. The generatedframePathreturned/frames/…, which resolves to the
site root — so a GitHub Pages project site at/<repo>/, or any static host
serving the directory from a subfolder, loaded the page and then found no
images. It now resolves against the page itself and returns an absolute url.Absolute rather than relative on purpose: the engine hands these urls to a
worker, and a worker resolves a relative url against its own script inlib/
rather than against the page, so a relative path would 404 there while the
poster image worked.Only the
htmltemplate changes. The other three serve frames frompublic/
at the site root, where/frames/…is correct — and their generated contract
still says, as it always did, that a deploy under a subdirectory needs that
function edited. -
A zero-build page ignored the title and description in its own story.
components/story.jsdeclares both, and the framework templates put them in
the document head on every render — Next reads them for<title>and for Open
Graph. Thehtmltemplate has no render step, so they were dropped without a
word: the body said one thing while the browser tab and every social preview
said whatever the template shipped with.framesnow writes both into the
head, and rewrites only tags that are already there — a page whose author
deleted its<title>did that on purpose.
Changed
-
The package stops describing itself as a Next.js tool. Four stacks ship.
The npm description and keywords, the plugin manifest, the marketplace entry
and--helpnow name all four or none — a builder on Nuxt or Astro previously
read the front page and correctly concluded, from what it said, that the tool
was not for them. -
The README leads with what you get instead of with prose. The outcome, all
four stacks and one install command are inside the first twenty lines. The CLI
reference, the templates table, the deep half of Why it looks right, the
git-and-frames explanation and the contributing instructions moved to
docs/en/, linked rather than inlined. Nothing was deleted.
Added
-
A live demo page, built by this tool from real footage and published from
CI on every release. -
Two issue forms. One for showing a page you built — with a separate,
refusable checkbox before it can appear in the README gallery, and a stated
24-hour removal policy — and one for bug reports. -
npm run samplefor anyone working on this repository: scaffolds a real
project, runs the real pipeline, and starts it, so a change can be looked at
rather than only tested.
v0.5.0 — the scrub stops looking like a slideshow
Four defects in how the sequence is drawn, all found by building a real page on
real footage rather than by reading the code. Three of them only appear on a
moving camera; the fourth only appears once you stop scrolling and look.
Fixed
-
The footage advanced a whole frame at a time. The draw loop painted the
single nearest decoded frame, so fifty frames over four hundred vh stepped
every eight vh. Easing the position never helped: it moved smoothly between
the same discrete images. The frame below and the frame above are now
composited, the second at the fraction between them. Sampled across one
frame's worth of scroll: one distinct image before, eleven after.It costs one
drawImageper paint and no extra memory — the two frames are
adjacent, so the decode window already holds both. When the upper one is
still in flight the base holds instead: half of a missing frame is not half a
transition, it is a flash of the wrong picture. -
A stopped page sat between two frames and showed both. Blending is right
while the picture is moving and wrong the moment it is not. On a camera
walking through a room the mean difference between adjacent frames is 20.8 of
255 at sixty frames, and at that distance a half mix reads as a double
exposure rather than as motion blur — invisible while scrolling, the first
thing you see when you stop. The drawn position now resolves onto a whole
frame once the scrub settles, and reports when it has arrived so the loop
still parks. -
A stopped page showed two headings at once. Same argument, applied to the
words. The crossfade holds each beat at full strength for most of its stretch
and hands over quickly in the middle, which is right while a reader is moving
through the story and wrong wherever they come to rest inside a handoff. The
copy now resolves onto whichever beat the crossfade already had winning.
Measured on a real page: beat opacities[0, 0, 0.70, 0.30, 0]mid-scrub,
[0, 0, 1, 0, 0]once settled.The pair still sums to 1 the whole way through, so no scroll position is left
with no copy on it. -
Two beats were dim through most of every handoff. The crossfade ramped
linearly all the way to the neighbour, so each beat was at full opacity for a
single instant and washed out everywhere else. On the page used to measure it,
62% of the scroll had two beats both part-lit and only 18% had any beat fully
lit. It now holds each beat either side of its position and does the swap with
a smoothstep across the middle of the gap: 20% and 70% on the same page.
SCROLL_MATH_VERSION is 1.3.0.
Added
-
The page comes to rest on a beat. An anchor per beat plus
scroll-snap-type: y mandatory, so a reader always lands where the copy is
clearest. The browser owns the gesture — nothing writes toscrollY— so it
does not fight native scroll, scroll anchoring or the keyboard.Beats are pulled onto whole frames first, which is what makes the landing
worth anything:atis authored against what the footage is doing and lands
mid-frame, so an unaligned anchor would drop the reader somewhere the picture
then has to correct itself. The move is at most half a frame, well inside the
stretch each beat holds.This imposes one rule on your layout: put your sections after the runway,
as its siblings. Every template does, and[data-scrollytelling-runway] ~ *
is a snap point for that reason. Mandatory snapping means the page must always
rest on one, so a section the selector cannot reach has nowhere legal to stop
and the browser drags the reader back to the last beat — everything below the
hero becomes unreachable, with nothing in the console to say so.Opt out with
data-scrollytelling-snap="off"on thehtmlelement. Reduced
motion opts out on its own.
Existing projects: all of this is in the engine your project carries a copy
of, so re-run scrollytelling scaffold . to take it —
scrollytelling scaffold . --diff reports what moved and neither overwrites a
file you have edited. If you have edited your page, check that your sections are
siblings of the runway before you upgrade, or turn snapping off.
v0.4.2 — the skill asks which template
Fixed
-
The skill never asked which template to use. Three templates besides
nexthave shipped since 0.3.0, and the skill documented all four — but only
as an optional--templateflag onscaffold, withnextas the stated
default and nothing telling the agent to raise the choice at all. Its
description named Next.js and nothing else. Both together meant every run
scaffoldednextsilently, whatever the surrounding project was.Choosing the template is its own step now, ahead of agreeing the page, with
the trade-off spelled out per template — because the choice decides which
files every later step touches, and is expensive to change afterwards.
scaffoldis documented as taking--templateexplicitly, even fornext.Skill file only. The CLI already accepted all four names and still does.
-
Three version numbers disagreed about which release this is. The plugin
manifest still said 0.3.0 and the skill's own metadata still said 0.1.0, both
left behind whilepackage.jsonmoved. All three read 0.4.2 now, and a
release bumps all three from here.The
package.jsoninside each template stays at 0.1.0 on purpose: that is the
version of the site somebody scaffolds, not of the tool that scaffolded it.
v0.4.1 — pages with more than a hero
Fixed
Four defects, all of which only appear once the page is more than the hero.
Found by building a real landing page on the Astro template.
-
The end of the sequence was unreachable. Progress came from the document's
height, which is the runway's height only on a page that is nothing but the
hero. With sections below it the hero unstuck at around 63% of the document,
so the last third of the frames never drew and the final beat never appeared.
The engine now measures the runway — the element marked
data-scrollytelling-runway, which every template renders — and falls back to
the document only when there is none, saying so in the console rather than
losing the tail of a sequence quietly.runwayProgressandrunwayScrollTop
are the new pure functions;SCROLL_MATH_VERSIONis1.2.0. -
The overlays escaped the hero. Beats, the progress bar, the scroll hint and
the loading pill were fixed to the viewport, so scrolling past the hero left
the last heading sitting across whatever came next. They are absolute inside
the sticky container now, and end where it ends. -
Rotating the phone moved the reader. The restore after a resize used the
document formula too, so a visitor half way through the story came out of the
rotation somewhere else in it. -
anchor: "bottom"ignoredalign. Bottom-anchored beats were centred
regardless, so two of them in a row crossfaded on top of each other with no
way to separate them from the story file.alignnow places them
horizontally, and both the runtime scrim andframes --checkread the band
the copy actually occupies.
Also: under reduced motion the runway now collapses. A server-rendered template
sizes it before it can know the setting, which left a reduced-motion visitor
with the story as prose followed by several screens of dead scroll under a stuck
image.
Existing projects: these fixes are in the engine your project carries a copy
of, so re-run scrollytelling scaffold . to take them —
scrollytelling scaffold . --diff reports what moved, and neither overwrites a
file you have edited. That last part matters here: the page markup gained
data-scrollytelling-runway on the element the hero sticks inside, and if you
have edited that file, adding the attribute is the one change you have to make
by hand. Without it the page keeps scrubbing against the document, which is only
correct while the hero is the whole page — the engine now says so in the console
when it happens.
v0.4.0 — the Nuxt template
Added
-
A Nuxt template.
scrollytelling scaffold ./my-site --template nuxt—
Nuxt 4 with Vue single-file components, server-rendered like the Astro one, so
the story outline and the opening frame are in the HTML before any JavaScript
runs. The story lives inapp/components/story.js.Two things are specific to Nuxt.
nuxt.config.tsnarrows the component scan
to.vue, because Nuxt would otherwise registerframes.jsandstory.jsas
components — that is what lets them keep the name and place they have on every
other template. Andapp.vuedisposes the engine on unmount, because Nuxt
keeps the page alive across client-side navigation and the scroll listener,
animation frame and decoded frames would otherwise outlive the page.Nothing else moved: the engine, the stylesheet, the worker and the frame
pipeline are the ones the other three templates already use.
v0.3.0 — three templates, one engine
Three templates instead of one, running the same engine.
Added
-
scaffold --template <name>.nextis still the default;astroand
htmlare new, and--templatewith no name lists them. The project records
which one it came from, so laterframesand--diffruns need no flag, and
scaffolding a different template over an existing project is refused rather
than leaving a tree that is neither. -
An Astro template. Ships no framework JavaScript, only the engine.
-
A zero-build HTML template. No
package.json, notsconfig, no bundler,
nothing to install. It does need a real HTTP server: module scripts and web
workers are same-origin only, sofile://will not work. Its story outline is
regenerated fromcomponents/story.jsbyframes, between markers in
index.html— edit the story, not the markup.
Changed
-
The scrubbing runtime moved into
lib/, shared by every template. A fix
to the decode window, the easing or the scrim is now one change rather than
one per template.ScrollSequence.tsxwent from 687 lines to about 135: the
engine owns the canvas, worker, decode window, animation frame, listeners,
reduced motion and the copy overlays, and the adapter hands it a container. -
The engine ships its own stylesheet, keyed off data attributes and
themeable through custom properties (--st-beat-heading-size,
--st-scrim-falloff, and the rest). Restyling a beat no longer means editing
markup you no longer own. It also takes the story outline, which was
previously hidden by a Tailwind utility — so a template without Tailwind gets
the same behaviour. -
The frames contract and the story are now JavaScript with types beside
them —frames.js+frames.d.ts,story.js+story.d.ts. A page with
no build step cannot import TypeScript, and those are exactly the files it has
to import. Editors and builds still check them: the templates that have a type
checker turn oncheckJs, so a mistypedalignstill fails.Existing projects: re-run
scrollytelling scaffold .to pick up the new
files. It never overwrites anything you have edited, and
scrollytelling scaffold . --diffreports what moved. Yourstory.tskeeps
working until you switch; the generated contract is what changes name.
Fixed
-
A page whose opening frames all decoded could report "no frames found".
The opening request covers the whole decode window, but only about half of
those are opening frames, and readiness counted every failure so far. -
Web workers were not emitted by every bundler. The engine constructed its
worker indirectly, and bundlers detect only the literal
new Worker(new URL(..., import.meta.url))form. Turbopack traced it anyway;
Vite did not, so an Astro build shipped no worker and fell back to
main-thread decoding with only a console warning.
v0.2.0 — two silent failures fixed
Published to npm: @danhnm1203/scrollytelling@0.2.0, with build provenance attesting it was built from this commit by the release workflow.
npx @danhnm1203/scrollytelling scaffold ./my-site
Fixed
A decode worker that failed to load hung the page forever. A worker whose URL 404s still constructs — the browser hands back a valid Worker and reports the failure asynchronously — so the try/catch around construction never fired, and the main-thread fallback was gated on the worker being absent. Every frame posted went to a worker that would never answer, nothing cleared them, and the page sat at a loading percentage indefinitely with no error, no console output and no timeout. It now falls back to main-thread decoding, re-requests the frames that were in flight, and warns once naming the URL that failed. (#38)
A freshly scaffolded project could not be built. scaffold ./site && npm install && npm run build failed before any frames were generated. Next typechecks during a build, and the placeholder declared SEQUENCES as [] as const — the tuple type readonly [] — so the three call sites that index it did not compile. This affected the whole of a project's life before its first frames run, which is exactly where a new user starts. (#40)
A page whose opening frames all decoded could report "no frames found". The opening request covers the whole decode window, but only about half of those are opening frames. Readiness was checked against every failure so far, so frames beyond the opening set failing first could push a perfectly renderable page into its empty state.
Changed
The runtime's decisions — load-state transitions, which frames to fetch and release, the draw parameters, and the choice between worker and main-thread decoding — moved into lib/scroll-engine-state.mjs: plain JavaScript, no DOM, unit-tested directly. The scrubbing component now applies those decisions rather than making them.
Generated projects gain lib/scroll-engine-state.mjs and its .d.ts. A new scaffold installs them automatically. An existing project picks them up by re-running scrollytelling scaffold ., which never overwrites files you have edited — scrollytelling scaffold . --diff reports what has moved. That new scaffold surface is why this is a minor rather than a patch.
The generated frames placeholder is annotated rather than as const, so it typechecks before any footage has been processed.
Internal
package.jsoncarried the unscoped namescrollytelling, which belongs to a different package on npm. Releases have always gone out scoped; the manifest now matches, so the tag-driven workflow can publish. This is the first release to go through it.*.tsbuildinfois gitignored —templates/sets"incremental": trueand ships in the package, so a stray build artifact would have been published.- Three stale ASCII diagrams corrected; two described code that had already changed.
Full changelog: CHANGELOG.md