Catalog / Recipe
Mask settle
A picture's frame opens and the picture settles as it crosses the viewport, scrubbed by scroll, on transforms only.
- Family
- scroll
- Status
- lab
- Used for
- FigureGalleryListingCard grid
- Size
- 1,634 of 1,700 bytes, gzipped
mount(element)The frame scales up from slightly small and the picture inside scales down from large, both driven by the frame’s own passage through the viewport. The frame’s scale is undone in the picture’s and a little more is added, so the picture settles inward while its window grows. Where the browser has no view timelines (Firefox before 159, Safari before 26) the same keyframes are scrubbed by script, once a frame while the page scrolls: the same picture, on the main thread. Nothing is computed: the browser scrubs two transforms on the compositor, and when the scroll stops, so does everything.
Use
import { mount } from './mask-settle';
mount(document.querySelector('.gallery'));
How it moves
The product of the two starting scales is what the picture is seen at,
and overscale is that product: the recipe divides it by the frame’s
scale to get the picture’s own. At exactly 1 the picture is still at
the first instant and the move is a pure unmask. Above 1 the picture
also settles inward. The two scales interpolate linearly and their
product does not, so the picture breathes by a percent or two in the
middle of the range.
The second mode, Rise, is the picture rising into its frame on a translation. A true wipe from one edge with a perfectly still picture needs a middle box between the frame and the picture, which this recipe would have to make by wrapping the image; it wraps nothing, so it does not offer that.
Options
| Option | Default | Meaning |
|---|---|---|
mode |
0 | Settle, or Rise |
inset |
0.14 | How much smaller the frame starts |
overscale |
1.18 | Scale the picture is seen at when it starts; 1 holds it still |
end |
2 | Where the move ends: entry 100%, cover 30%, cover 45%, cover 60% |
drift |
0 | Parallax share. Off by default; what is set is cropped from every picture |
headerOffset |
0 | A sticky header’s height, so the range starts below it |
easing |
0 | Linear, or front loaded |
Versions
| Version | What it is |
|---|---|
| Settle | The defaults: the frame opens, the picture settles a little |
| Rise | The picture rises into its frame |
| Drift | A slow parallax inside the frame, over the whole passage |
| Pop | A larger inset and a front-loaded curve: it arrives with a push |
Where it must not go
- The first screen. A frame in view at load is scrubbed from where the page opens, and the reader never sees it settle.
- A chart, a table, a figure the reader must compare. A scaled frame misstates it until it settles.
- Text. Scaling type is the one scroll motion everyone notices.
Learned while building it
clip-pathrepaints every frame in Chrome. The transform-only construction stays on the compositor and gives the same picture.- The frame must clip. Without
overflow: clipthe picture’s overscale shows its edge outside the frame. - Ask for the scale that is seen, not the picture’s own. The picture is inside the frame and shrinks with it. Set directly, a picture at 1.18 in a frame at 0.86 is seen at 1.015: the two cancel, the defaults moved nothing, and the recipe looked broken.
- A recipe that does nothing in one browser reads as broken. The first version left the page as laid out without view timelines, and in Firefox 157 that was taken for a fault. The fallback pauses the same animations and sets their time from the frame’s box: read every box, then write every time, so a gallery costs one layout a frame. The frame’s own scale has to be taken out of its box first, or the move feeds its own progress.
- Drift is a price, not a free dial. A tenth of drift crops a tenth of every picture, always, and parallax is the motion most likely to bother someone.
/**
* mask-settle
*
* A picture's frame opens and the picture settles as the frame crosses
* the viewport. Scrubbed by scroll, on transforms only: a view timeline
* drives two scales, nothing is computed and nothing runs on the main
* thread, and the element's own style is the end state. Where there are
* no view timelines the same keyframes are scrubbed by hand, once a
* frame while the page scrolls. Under reduced motion, or without
* script, the page is simply finished.
*
* Copy this file. It imports nothing.
*
* import { mount } from './mask-settle';
* mount(document.querySelector('.gallery'));
*
* Every `img` inside whose parent is positioned is framed by that
* parent. The recipe wraps nothing and moves nothing.
*/
export interface Options {
/** Which move: 0 the frame opens around a still picture, 1 the picture rises into its frame. */
mode: number;
/** How much smaller the frame starts, as a share of its size. 0 is no move; 0.3 reads as a pop. */
inset: number;
/** Scale the picture is seen at when it starts, whatever its frame is doing. 1 holds it still while the frame opens; more and it settles inward. */
overscale: number;
/** Where the move ends: 0 entry 100%, 1 cover 30%, 2 cover 45%, 3 cover 60%. */
end: number;
/** Parallax share: how far the picture drifts inside its frame over the whole passage. 0 is off; what is set is cropped from every picture. */
drift: number;
/** Height of a sticky header, in px, so the range starts below it. */
headerOffset: number;
/** Scrub curve: 0 linear, 1 front loaded so most of the move is on entry. */
easing: number;
}
export const DEFAULTS: Options = {
mode: 0,
inset: 0.14,
overscale: 1.18,
end: 2,
drift: 0,
headerOffset: 0,
easing: 0,
};
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
const NOOP: Handle = { update() {}, destroy() {} };
const mounted = new WeakMap<HTMLElement, Handle>();
const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value));
const ENDS = ['entry 100%', 'cover 30%', 'cover 45%', 'cover 60%'] as const;
/**
* The two starting scales. Their product is what the picture is seen
* at, and it is the product that is asked for: the picture sits inside
* the frame and is scaled down with it, so its own scale is the one
* wanted over the frame's. Asked for directly, 1.18 on a frame at 0.86
* came to 1.015 on screen, and the move was seen as nothing.
*/
export function settleScales(o: Options): { frame: number; picture: number } {
const frame = 1 - clamp(o.inset, 0, 0.5);
return { frame, picture: clamp(o.overscale, 1, 2) / frame };
}
export function rangeOf(end: number): [start: string, end: string] {
return ['entry 0%', ENDS[clamp(Math.round(end), 0, ENDS.length - 1)] ?? 'cover 45%'];
}
/** The share of the whole passage each end is at; the first is entry 100%, the last the whole of it. */
const SHARES = [0, 0.3, 0.45, 0.6, 1];
/** A scrubbed animation's length, in ms. Any number: only the share of it is used. */
const SPAN = 1000;
/**
* How far along its range a frame is, 0 to 1, worked out as a view
* timeline would: from its top at the viewport's bottom edge. `top` and
* `height` are the frame's box as laid out, `room` the viewport less a
* sticky header. An end of 4 is the whole passage, which drift runs over.
*/
export function travelled(end: number, top: number, height: number, viewport: number, room: number): number {
const share = SHARES[clamp(Math.round(end), 0, 4)] ?? 1;
const length = share > 0 ? share * (room + height) : height;
return length > 0 ? clamp((viewport - top) / length, 0, 1) : 1;
}
export function mergeOptions(base: Options, next: Partial<Options>): Options {
const merged: Record<string, unknown> = { ...base };
for (const [key, standard] of Object.entries(DEFAULTS)) {
const value: unknown = (next as Record<string, unknown>)[key];
if (typeof value === typeof standard && (typeof value !== 'number' || Number.isFinite(value))) merged[key] = value;
}
return merged as unknown as Options;
}
interface Framed {
frame: HTMLElement;
img: HTMLImageElement;
frameStyle: string | null;
/** Each with the end of its range, for the hand that scrubs it. */
animations: [animation: Animation, end: number][];
}
export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
try {
return start(element, options);
} catch {
return NOOP;
}
}
function start(element: HTMLElement | null, explicit: Partial<Options>): Handle {
if (!element || typeof element.animate !== 'function') return NOOP;
// Firefox before 159 and Safari before 26 have no view timelines.
const native = typeof ViewTimeline !== 'undefined';
const asks = (query: string): boolean => typeof matchMedia === 'function' && matchMedia(query).matches;
if (asks('(prefers-reduced-motion: reduce)')) return NOOP;
mounted.get(element)?.destroy();
let options = mergeOptions(DEFAULTS, explicit);
const images = element instanceof HTMLImageElement ? [element] : Array.from(element.querySelectorAll('img'));
const framed: Framed[] = [];
for (const img of images) {
const frame = img.parentElement;
// No computed value at all (a bare test document) counts as static.
if (!frame || ['static', ''].includes(getComputedStyle(frame).position)) continue;
framed.push({ frame, img, frameStyle: frame.getAttribute('style'), animations: [] });
}
const easing = (): string => (Math.round(options.easing) === 1 ? 'cubic-bezier(0.2, 0.8, 0.3, 1)' : 'linear');
const timing = (frame: HTMLElement, range: [string, string]): KeyframeAnimationOptions => {
if (!native) return { fill: 'both', easing: easing(), duration: SPAN };
const offset = clamp(options.headerOffset, 0, 400);
const timeline =
offset > 0 && typeof CSS !== 'undefined' && 'px' in CSS
? new ViewTimeline({ subject: frame, axis: 'block', inset: [CSS.px(offset), CSS.px(0)] })
: new ViewTimeline({ subject: frame, axis: 'block' });
return { fill: 'both', easing: easing(), timeline, rangeStart: range[0], rangeEnd: range[1] };
};
const play = (): void => {
const { frame: f, picture: p } = settleScales(options);
const range = rangeOf(options.end);
const rise = Math.round(options.mode) === 1;
const drift = clamp(options.drift, 0, 0.3);
const add = (item: Framed, animation: Animation, end: number): void => {
if (!native) animation.pause();
item.animations.push([animation, end]);
};
for (const item of framed) {
// The frame clips, so the picture's overscale never shows an edge.
item.frame.style.overflow = 'clip';
if (rise) {
add(item, item.img.animate([{ translate: '0 100%' }, { translate: '0 0' }], timing(item.frame, range)), options.end);
} else {
add(item, item.frame.animate([{ scale: `${f}` }, { scale: '1' }], timing(item.frame, range)), options.end);
add(item, item.img.animate([{ scale: `${p}` }, { scale: '1' }], timing(item.frame, range)), options.end);
}
if (drift > 0) {
// The picture is scaled up by the drift and slid by it over the
// whole passage: a layer moving at nearly the page's speed.
add(
item,
item.img.animate(
[{ transform: `scale(${1 + drift}) translateY(${(drift / 2) * 100}%)` }, { transform: `scale(${1 + drift}) translateY(${(-drift / 2) * 100}%)` }],
{ ...timing(item.frame, ['cover 0%', 'cover 100%']), composite: 'add' },
),
4,
);
}
}
scrub();
};
// By hand: every box is read, then every animation is set, so the
// page is laid out once a frame however many pictures there are. The
// frame's own scale is taken out of its box, about its centre.
let waiting = 0;
const scrub = (): void => {
waiting = 0;
if (native) return;
const room = innerHeight - clamp(options.headerOffset, 0, 400);
const boxes = framed.map(({ frame }) => {
const box = frame.getBoundingClientRect();
return [box.top + (box.height - frame.offsetHeight) / 2, frame.offsetHeight] as const;
});
framed.forEach((item, at) => {
const [top, height] = boxes[at] ?? [0, 0];
for (const [animation, end] of item.animations) animation.currentTime = travelled(end, top, height, innerHeight, room) * SPAN;
});
};
const moved = (): void => {
waiting ||= requestAnimationFrame(scrub);
};
if (!native) {
// Captured, so a scroll inside any box the pictures sit in is heard too.
addEventListener('scroll', moved, { passive: true, capture: true });
addEventListener('resize', moved);
}
const clear = (): void => {
for (const item of framed) {
for (const [animation] of item.animations) animation.cancel();
item.animations = [];
}
};
const restore = (): void => {
for (const item of framed) {
// Read before removing: Chrome leaves style="" behind otherwise.
item.frame.getAttribute('style');
if (item.frameStyle === null) item.frame.removeAttribute('style');
else item.frame.setAttribute('style', item.frameStyle);
}
};
const handle: Handle = {
update(next) {
options = mergeOptions(options, next);
clear();
play();
},
destroy() {
cancelAnimationFrame(waiting);
removeEventListener('scroll', moved, { capture: true });
removeEventListener('resize', moved);
clear();
restore();
if (mounted.get(element) === handle) mounted.delete(element);
},
};
try {
play();
} catch {
// Whatever could not be animated, the frames are handed back as they were.
handle.destroy();
return NOOP;
}
mounted.set(element, handle);
return handle;
}
/**
* Named versions of the effect: sets of options that belong together.
* The first is the defaults. `mount(element, PRESETS.Pop)`.
*/
export const PRESETS: Record<string, Partial<Options>> = {
Settle: {},
Rise: { mode: 1, end: 1 },
Drift: { drift: 0.08, end: 3 },
Pop: { inset: 0.3, overscale: 1.4, easing: 1 },
};