The ghost story behind Chrometober’s scroll-driven book
Hot on the heels of Designcember, the team wanted this year’s Chrometober to shine a light on community and Chrome team web content. Where Designcember showcased Container Queries, Chrometober is a showcase for the CSS scroll-linked animations API. The result is a scrolling book experience at web.dev/chrometober-2022, with a festive Halloween theme.
The brief was to build something whimsical that highlights the scroll-linked animations API—while staying responsive, accessible, and ready to test drive the API polyfill still in active development. The project brought together a small cross-functional team: Tyler Reed on illustration and design, Jhey Tompkins as architectural and creative lead, Una Kravets as project lead, Bramus Van Damme as site contributor, Adam Argyle on accessibility review, and Aaron Forinton on copywriting.
From stickies to storyboard
The first ideas for Chrometober came out of a team offsite in May 2022, with scribbles about scrolling through scenes from a graveyard to a haunted house. An early concept let the user navigate sideways through scenes as blocks rotated and scaled in. But that approach raised concerns about delivering a solid experience across device sizes, so the team pivoted.
The fallback design drew from a 3D-CSS book demo built with GreenSock’s ScrollTrigger in 2020. In that demo, pages turned as the user scrolled—an interaction the scroll-linked animations API can handle natively, and one that pairs naturally with scroll-snap.
The plan became a book you scroll through, with content broken into isolated blocks. Those blocks could be composed into scenes, letting the team mix and match features without tangled dependencies. Alongside the core pages, the team built in "dashes of whimsy"—Easter eggs like a haunted house portrait whose eyes track the pointer, media-query-driven animations, and a zombie bunny that rises and slides along the x-axis on scroll.
Learning the API through prototyping
Before the features came the book itself, and building it meant getting friendly with the emerging CSS scroll-linked animations API. The API isn't available in any browser yet, so the interactions team has built a polyfill to iterate on the API shape. That makes experimental projects like this a natural sandbox for the feature set—and a channel for feedback.
At a high level, the API links animations to scroll. It can’t trigger an animation on scroll (that may come later), and the supported behaviors split into two categories:
- Animations that react to scroll position.
- Animations that react to an element's position in its scrolling container.
The second category uses ViewTimeline, set via an animation-timeline property. Here’s a minimal example:
.element-moving-in-viewport {
view-timeline-name: foo;
view-timeline-axis: block;
}
.element-scroll-linked {
animation: rotate both linear;
animation-timeline: foo;
animation-delay: enter 0%;
animation-end-delay: cover 50%;
}
@keyframes rotate {
to {
rotate: 360deg;
}
}
view-timeline-name declares the timeline and its axis—block here refers to the logical block axis. The animation links to scroll via animation-timeline. At the time of writing, the phases are defined with animation-delay and animation-end-delay: in this example, start the animation when the element enters (enter 0%) the scrolling container, and finish when it covers 50% (cover 50%).
You can also use the same element’s own view-timeline as its animation-timeline, which is handy for list entry effects that usually rely on IntersectionObserver logic:
element-moving-in-viewport {
view-timeline-name: foo;
view-timeline-axis: block;
animation: scale both linear;
animation-delay: enter 0%;
animation-end-delay: cover 50%;
animation-timeline: foo;
}
@keyframes scale {
0% {
scale: 0;
}
}
In this case, "Mover" scales up as it enters the viewport and triggers "Spinner" to rotate.
What became clear from early experiments is that the API pairs exceptionally well with scroll-snap—a combination that turns out to be a natural fit for snapping page turns in a book.
Making the pages turn
The working prototype scrolls horizontally with snap points at each page turn, and the triggers are highlighted with dashed borders in the demo. The markup is sparse:
<body>
<div class="book-placeholder">
<ul class="book" style="--count: 7;">
<li
class="page page--cover page--cover-front"
style="--index: 0;"
>
<div class="page__paper">
<div class="page__side page__side--front"></div>
<div class="page__side page__side--back"></div>
</div>
</li>
<!-- Markup for other pages here -->
</ul>
</div>
<div>
<p>intro spacer</p>
</div>
<div data-scroll-intro>
<p>scale trigger</p>
</div>
<div>
<p>page trigger</p>
</div>
<!-- Markup for other triggers here -->
</body>
Pages snap open or closed depending on the scroll-snap alignment of the triggers:
html {
scroll-snap-type: x mandatory;
}
body {
grid-template-columns: repeat(var(--trigger-count), auto);
overflow-y: hidden;
overflow-x: scroll;
display: grid;
}
body > [data-scroll-trigger] {
height: 100vh;
width: clamp(10rem, 10vw, 300px);
}
body > [data-scroll-trigger] {
scroll-snap-align: end;
}
Rather than wiring up ViewTimeline in CSS individually, the prototype builds them in JavaScript with the Web Animations API. That approach lets the code loop over a set of triggers and create each timeline programmatically:
const triggers = document.querySelectorAll("[data-scroll-trigger]")
const commonProps = {
delay: { phase: "enter", percent: CSS.percent(0) },
endDelay: { phase: "enter", percent: CSS.percent(100) },
fill: "both"
}
const setupPage = (trigger, index) => {
const target = document.querySelector(
`[data-scroll-target="${trigger.getAttribute("data-scroll-trigger")}"]`
);
const viewTimeline = new ViewTimeline({
subject: trigger,
axis: 'inline',
});
target.animate(
[
{
transform: `translateZ(${(triggers.length - index) * 2}px)`
},
{
transform: `translateZ(${(triggers.length - index) * 2}px)`,
offset: 0.75
},
{
transform: `translateZ(${(triggers.length - index) * -1}px)`
}
],
{
timeline: viewTimeline,
…commonProps,
}
);
target.querySelector(".page__paper").animate(
[
{
transform: "rotateY(0deg)"
},
{
transform: "rotateY(-180deg)"
}
],
{
timeline: viewTimeline,
…commonProps,
}
);
};
const triggers = document.querySelectorAll('[data-scroll-trigger]')
triggers.forEach(setupPage);
Each trigger gets its own ViewTimeline, and its associated page is animated against that timeline. To turn a page, the animation rotates an inner element on the y-axis; the page itself translates along the z-axis so the turn reads like a real book.
Composing pages
Each page in the book is defined by a configuration array. A page object describes the content, backdrop, and metadata for that spread. The array is passed to the Book component, which applies the scrolling mechanism and instantiates each page. The prototype’s mechanism is reused, but the ViewTimeline instances are created once and shared globally rather than recreated per page.
Pages themselves are list items inside a list. The page configuration is passed to each Page instance, which uses Astro’s slot feature to insert content. This setup does most of the structural heavy lifting once, meaning contributors can focus on content without disturbing the core page-turn code.
<ul class="book">
{
pages.map((page, index) => {
const FrontSlot = page.front.content
const BackSlot = page.back.content
return (
<Page
index={index}
cover={page.cover}
aria={page.aria}
backdrop={
{
front: {
light: page.front.backdrop,
dark: page.front.darkBackdrop
},
back: {
light: page.back.backdrop,
dark: page.back.darkBackdrop
}
}
}>
{page.front.content && <FrontSlot slot="front" />}
{page.back.content && <BackSlot slot="back" />}
</Page>
)
})
}
</ul>
<li
class={className}
data-scroll-target={target}
style={`--index:${index};`}
aria-label={aria}
>
<div class="page__paper">
<div
class="page__side page__side--front"
aria-label={`Right page of ${index}`}
>
<picture>
<source
srcset={darkFront}
media="(prefers-color-scheme: dark)"
height="214"
width="150"
>
<img
src={lightFront}
class="page__background page__background--right"
alt=""
aria-hidden="true"
height="214"
width="150"
>
</picture>
<div class="page__content">
<slot name="front" />
</div>
</div>
<!-- Markup for back page -->
</div>
</li>
Split scenes with backdrops
The shift to a book format simplified sectioning: each spread is a scene from the original design. Because an aspect ratio was fixed for the book, each backdrop can use a element. Setting that element to 200% width and positioning it with object-position per page side yields a continuous backdrop across the spread. The book’s features are sized with responsive viewport units, while font sizing uses an inline container query unit derived from calc().
Page content with atomic components
Consider page three, which shows an owl popping up in a tree. It’s populated by a PageThree Astro component. Astro components look like HTML but include a code fence for imports and logic. Page components are atomic; they compose smaller feature components. Page three includes a content block and the interactive owl, as separate components.
Content blocks are the links to articles inside the book. A configuration object drives them. The block config is imported where needed, and the relevant block is passed to the ContentBlock component. The page component handles positioning; general block styles are co-located with the component itself.
{
"contentBlocks": [
{
"id": "one",
"title": "New in Chrome",
"blurb": "Lift your spirits with a round up of all the tools and features in Chrome.",
"link": "https://www.youtube.com/watch?v=qwdN1fJA_d8&list=PLNYkxOF6rcIDfz8XEA3loxY32tYh7CI3m"
},
…otherBlocks
]
}
<ContentBlock {...contentBlocks[3]} id="four" />
<style is:global>
.content-block--four {
left: 30%;
bottom: 10%;
}
</style>
.content-block {
background: hsl(0deg 0% 0% / 70%);
color: var(--gray-0);
border-radius: min(3vh, var(--size-4));
padding: clamp(0.75rem, 2vw, 1.25rem);
display: grid;
gap: var(--size-2);
position: absolute;
cursor: pointer;
width: 50%;
}
The owl’s scroll-linked animation
The owl is a small example of interaction tied to the shared ViewTimeline. The component imports inline SVG and uses Astro’s Fragment. Positioning styles live with the component. Extra CSS defines transform behavior, notably using transform-box to make the transform-origin relative to the object’s bounding box. The owl scales from bottom center via transform-origin: 50% 100%.
The script checks for motion preferences. If the user has none, it links an owl animation to scroll using the Web Animations API. The translate property is tied to CHROMETOBER_TIMELINES[1], a ViewTimeline generated for page turns, via the timeline property. Using the enter phase, the owl starts moving when a page is 80% turned and finishes translation at 90%.
Interactive features across the book
As content blocks filled out, the project expanded with varied interactions, some scroll-driven, others powered by standard CSS animations. The bat, for example, flies in and out with page turns.
Backdrops that change at night
The backdrops also support light and dark modes. Owing to media queries inside the element, the checks for color scheme preference and delivers the appropriate backdrop variant. This affects more than the art: on page two, pumpkins react to the user’s color scheme. The SVG’s flame circles scale up and animate only in dark mode.
Portrait eyes follow the pointer
Page 10’s portrait is a tracking illusion. The eyes are duplicated; the invisible originals serve as reference. A mapRange function converts input ranges to output ranges, mapping a pointer position to pixel translation values. For each eye, the input is the center point plus or minus a pixel margin; the output is the eye’s potential translation range.
A pointermove listener on the window recalculates eye center points from their bounds and maps the pointer to values set as CSS custom properties on the eyes. The CSS side uses clamp() to let each eye differ behaviorally without further script changes.
Canvas cursor trail on page six
Page six’s spellbinding fox has a canvas-based cursor trail. A sits above page content with pointer-events: none so that links beneath remain clickable. Like the portrait, it listens for pointermove on window. Each event appends an object to an array with coordinates and a random hue. The mapRange utility maps pointer movement to the object’s size and fall rate.
Rendering runs in a requestAnimationFrame loop gated by an IntersectionObserver, so the trail only renders when the page is in view. Each frame reduces object size and shifts position by its rate, producing the falling trail. Objects shrink to nothing and are removed from the array. When the page leaves view, event listeners are removed, animation cancels, and the array clears.
Making Chrometober accessible for everyone
Before release, the Chrometober experience had to meet accessibility standards. Since a core goal was to create an experience everyone could enjoy, the team partnered with Adam Argyle to help prepare for a formal accessibility review. Several key areas were addressed:
- Semantic HTML structures such as
<main>for the book,<article>for each content block, and<abbr>elements before acronyms are used. This makes navigation possible for users relying on headings, links, and landmarks. altattributes for all images, includingtitleelements for inline SVGs.- ARIA attributes for improved usability, including
aria-labelfor page identification andaria-describedbyon the “Read more” links so the link destination’s text is announced. - Full-card clickability, so the entire content block acts as a link target, not just the text link.
- The
IntersectionObservernot only pauses animation for offscreen pages; it also applies theinertattribute to those pages. This way, screen reader users get the same content, and focus stays in the currently visible page, avoiding accidental tabbing to hidden pages. - Respect for
prefers-reduced-motionsettings via media queries.

Lessons from testing
Beyond showcasing community content, Chrometober was a real-world test bed for the scroll-linked animations API polyfill in development. The team dedicated a session during the New York summit to testing and fixing issues before launch.

The biggest rendering issue surfaced on iOS: the book’s pages were sized with viewport units, which caused problems when the device had a notch. The fix: adding viewport-fit=cover to the meta viewport tag:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
This testing phase also exposed bugs in the API polyfill. Bramus filed the issues and solved them in the polyfill repository, getting the fixes merged. Notably, page view caching was added as a performance enhancement, a change driven directly by what the team found in real use.


Wrapping up
Chrometober delivered a playful, scrolling reading experience built to celebrate community content. It also pushed the polyfill forward: the project generated crucial feedback that shaped engineering improvements. As a result, the experience reinforces the whole point—a great demo that's also accessible, and a launchpad for testing new web platform features under real-world conditions.




