Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added src/assets/sequence/001-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/002-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/003-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/004-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/005-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/006-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/007-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/008-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/009-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/010-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/011-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/012-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/013-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/014-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/015-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/016-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/017-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/018-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/019-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/020-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/021-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/022-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/023-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/024-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/025-frame.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/sequence/026-frame.webp
Binary file added src/assets/sequence/027-frame.webp
Binary file added src/assets/sequence/028-frame.webp
Binary file added src/assets/sequence/029-frame.webp
Binary file added src/assets/sequence/030-frame.webp
Binary file added src/assets/sequence/031-frame.webp
Binary file added src/assets/sequence/032-frame.webp
Binary file added src/assets/sequence/033-frame.webp
Binary file added src/assets/sequence/034-frame.webp
Binary file added src/assets/sequence/035-frame.webp
Binary file added src/assets/sequence/036-frame.webp
Binary file added src/assets/sequence/037-frame.webp
Binary file added src/assets/sequence/038-frame.webp
Binary file added src/assets/sequence/039-frame.webp
Binary file added src/assets/sequence/040-frame.webp
Binary file added src/assets/sequence/041-frame.webp
Binary file added src/assets/sequence/042-frame.webp
Binary file added src/assets/sequence/043-frame.webp
Binary file added src/assets/sequence/044-frame.webp
Binary file added src/assets/sequence/045-frame.webp
Binary file added src/assets/sequence/046-frame.webp
Binary file added src/assets/sequence/047-frame.webp
Binary file added src/assets/sequence/048-frame.webp
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useMotionValueEvent, useScroll } from "motion/react";
import { useRef } from "react";
import type { ImageSequenceRef } from "../image-sequence";
import { ImageSequence } from "../image-sequence";

export const meta = { layout: "fullscreen", mode: "iframe" };

const frames = [
"/src/assets/sequence/001-frame.webp",
"/src/assets/sequence/002-frame.webp",
"/src/assets/sequence/003-frame.webp",
"/src/assets/sequence/004-frame.webp",
"/src/assets/sequence/005-frame.webp",
"/src/assets/sequence/006-frame.webp",
"/src/assets/sequence/007-frame.webp",
"/src/assets/sequence/008-frame.webp",
"/src/assets/sequence/009-frame.webp",
"/src/assets/sequence/010-frame.webp",
"/src/assets/sequence/011-frame.webp",
"/src/assets/sequence/012-frame.webp",
"/src/assets/sequence/013-frame.webp",
"/src/assets/sequence/014-frame.webp",
"/src/assets/sequence/015-frame.webp",
"/src/assets/sequence/016-frame.webp",
"/src/assets/sequence/017-frame.webp",
"/src/assets/sequence/018-frame.webp",
"/src/assets/sequence/019-frame.webp",
"/src/assets/sequence/020-frame.webp",
"/src/assets/sequence/021-frame.webp",
"/src/assets/sequence/022-frame.webp",
"/src/assets/sequence/023-frame.webp",
"/src/assets/sequence/024-frame.webp",
"/src/assets/sequence/025-frame.webp",
"/src/assets/sequence/026-frame.webp",
"/src/assets/sequence/027-frame.webp",
"/src/assets/sequence/028-frame.webp",
"/src/assets/sequence/029-frame.webp",
"/src/assets/sequence/030-frame.webp",
"/src/assets/sequence/031-frame.webp",
"/src/assets/sequence/032-frame.webp",
"/src/assets/sequence/033-frame.webp",
"/src/assets/sequence/034-frame.webp",
"/src/assets/sequence/035-frame.webp",
"/src/assets/sequence/036-frame.webp",
"/src/assets/sequence/037-frame.webp",
"/src/assets/sequence/038-frame.webp",
"/src/assets/sequence/039-frame.webp",
"/src/assets/sequence/040-frame.webp",
"/src/assets/sequence/041-frame.webp",
"/src/assets/sequence/042-frame.webp",
"/src/assets/sequence/043-frame.webp",
"/src/assets/sequence/044-frame.webp",
"/src/assets/sequence/045-frame.webp",
"/src/assets/sequence/046-frame.webp",
"/src/assets/sequence/047-frame.webp",
"/src/assets/sequence/048-frame.webp",
];

const ImageSequencePreview = () => {
const containerRef = useRef<HTMLDivElement>(null);
const sequenceRef = useRef<ImageSequenceRef>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"],
});

useMotionValueEvent(scrollYProgress, "change", (progress) => {
sequenceRef.current?.scrub(progress);
});

return (
<div ref={containerRef}>
<div className="sticky top-0 h-screen w-full overflow-hidden">
<ImageSequence frames={frames} ref={sequenceRef} />
</div>
<div className="h-[400vh]" />
</div>
);
};

export default ImageSequencePreview;
132 changes: 132 additions & 0 deletions src/foundations/components/image-sequence/image-sequence.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { ComponentPropsWithoutRef, Ref } from "react";
import { useCallback, useEffect, useImperativeHandle, useRef } from "react";
import { clamp } from "@/foundations/utils/math/clamp";

interface ImageSequenceRef {
scrub: (progress: number) => void;
}
Comment on lines +5 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big fan of this. Binding methods to refs is somewhat anti-pattern in react. We should brainstorm a better way of doing this, maybe through a context provider.


interface ImageSequenceProps extends ComponentPropsWithoutRef<"canvas"> {
frames: string[];
ref?: Ref<ImageSequenceRef>;
}

const ImageSequence = ({ frames, ref, ...props }: ImageSequenceProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const framesRef = useRef<HTMLImageElement[]>([]);

if (!frames.length) throw new Error("[image-sequence]: frames must not be empty");

const scrub = useCallback((progress: number) => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!context || !canvas) return;

const loaded = framesRef.current;
const index = clamp(0, Math.floor(progress * loaded.length), loaded.length - 1);
const image = loaded[index];

if (image) {
context.drawImage(image, 0, 0, canvas.width, canvas.height);
}
}, []);

useImperativeHandle(ref, () => ({ scrub }), [scrub]);

useEffect(() => {
framesRef.current = [];

let abort: AbortController | undefined;

const start = () => {
abort = progressivelyLoadFrames(frames, [16, 8, 4, 2, 1], (frame, index) => {
framesRef.current[index] = frame;
if (index === 0) {
const canvas = canvasRef.current;
if (canvas) {
canvas.width = frame.naturalWidth;
canvas.height = frame.naturalHeight;
}
scrub(0);
}
});
};

if (document.readyState === "complete") {
start();
} else {
window.addEventListener("load", start, { once: true });
}

return () => {
window.removeEventListener("load", start);
abort?.abort();
};
}, [frames, scrub]);

return (
<canvas
ref={canvasRef}
{...props}
style={{ width: "100%", height: "100%", objectFit: "cover", ...props.style }}
/>
);
};

// load frames progressively in steps
// examples: steps = [4,2,1] first renders every 4th frame, then every 2nd frame, then every 1st frame
function progressivelyLoadFrames(
images: string[],
steps: [...number[], 1],
onLoad: (image: HTMLImageElement, index: number) => void,
): AbortController {
const abortController = new AbortController();
const cache = new Map<string, HTMLImageElement>();

function loadImage(src: string): Promise<HTMLImageElement> {
const cached = cache.get(src);
if (cached) return Promise.resolve(cached);

return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
cache.set(src, img);
resolve(img);
};
img.onerror = () => reject(new Error(`Failed to load image ${src}`));
img.src = src;
});
}

async function runStep(step: number, rendered: number[]) {
const batch: Promise<HTMLImageElement>[] = [];

for (let i = 0; i < images.length; i += step) {
// wait for previous batch to finish, before starting a new one
if (i !== 0) await Promise.all(batch);

const src = images[i];
if (!src || rendered.includes(i) || abortController.signal.aborted) continue;

const imagePromise = loadImage(src);
batch.push(imagePromise);
void imagePromise.then((img) => {
onLoad(img, i);
rendered.push(i);
});
}
}

void (async () => {
const rendered: number[] = [];
for (const step of steps) {
if (abortController.signal.aborted) break;
await runStep(step, rendered);
}
})();

return abortController;
}

export type { ImageSequenceRef };
export { ImageSequence };
91 changes: 91 additions & 0 deletions src/foundations/components/image-sequence/page.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
title: Image Sequence
description: A canvas-based component that renders image sequences driven by an external progress value.
preview: image-sequence
files:
- src/foundations/components/image-sequence/image-sequence.tsx

dependencies:
- name: clamp
href: /utils/math
folder: components
---

## Features

- **Canvas Rendering**: Draws frames onto a `<canvas>` element for efficient frame-by-frame playback
- **Progressive Loading**: Loads frames in passes of decreasing density (`[16, 8, 4, 2, 1]`) so a rough preview appears quickly before full resolution fills in
- **Deferred Start**: Waits for the window `load` event before fetching frames, avoiding competition with critical page assets
- **External Control**: Exposes a `scrub(progress)` method via ref so the caller decides what drives the animation — scroll position, pointer, timeline, or anything else
- **Cleanup**: Aborts in-flight frame loading on unmount or when `frames` changes

## Usage

Frames must be defined at module level — outside the component — so the array reference stays stable across renders and does not restart frame loading on every render.

```tsx
const frames = ["/frame-0001.webp", "/frame-0002.webp", "/frame-0003.webp"];

const MyComponent = () => {
const containerRef = useRef<HTMLDivElement>(null);
const sequenceRef = useRef<ImageSequenceRef>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"],
});

useMotionValueEvent(scrollYProgress, "change", (progress) => {
sequenceRef.current?.scrub(progress);
});

return (
<div ref={containerRef}>
<div className="sticky top-0 h-screen w-full overflow-hidden">
<ImageSequence frames={frames} ref={sequenceRef} />
</div>
<div className="h-[400vh]" />
</div>
);
};
```
Comment on lines +26 to +50

@andrre-ls andrre-ls Jul 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should simplify this example. First, the should not use vite's glob import and instead pass in a "regular" array of images sources. Secondly, I think it'd be better to use motion's useScroll hook to drive the scroll progress.


## API Reference

### ImageSequence

<PropsTable
definition={{
frames: {
type: "string[]",
description:
"The sequence frames to render. Must be defined at module level to keep the reference stable across renders.",
required: true,
},
}}
/>

Accepts all standard `<canvas>` props. Default styles are `width: 100%`, `height: 100%`, `object-fit: cover` — override via the `style` prop.

### ImageSequenceRef

```ts
interface ImageSequenceRef {
scrub: (progress: number) => void;
}
```

`scrub` takes a progress value between `0` and `1` and draws the corresponding frame onto the canvas.

## Examples

### Scroll-driven

<Preview slug="image-sequence" />

## Frame Sorting

Frames are sorted numerically by the leading number in each filename before loading (e.g. `001-frame.webp`, `002-frame.webp`). Name your source files accordingly.

## Limitations

- `import.meta.glob` patterns must be static string literals, so the glob cannot be passed as a prop. Define it in the consuming file and pass the resulting array as `frames`.
Comment on lines +89 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already alluded this in previous comments, but these limitations should not exist. Both are coupling the component to the build framework, which is something no foundations component should ever do.

Loading