Catalog / Recipe
Listing reflow
Rows travel to their new places when a list is sorted or filtered: a same-document view transition in which every row is named as itself.
- Family
- page transition
- Status
- lab
- Used for
- ListingCard grid
- Size
- 1,330 of 1,400 bytes, gzipped
mount(element)The third way a thing travels: to its new place in the same list. The
rows are named as themselves (match-element: a name the browser
derives from the element’s identity, so no two can clash), the list is
reordered or filtered inside a transition, and each row goes where it
now belongs. A row that is filtered out fades; one that comes back
fades in.
Use
import { mount } from './listing-reflow';
const handle = mount(document.querySelector('.cards'));
handle.update({ order: 1, descending: true });
handle.update({ tag: 2 });
handle.change(() => list.append(row));
The list says what it can be ordered and filtered by, in attributes.
Each row may carry data-sort-1, data-sort-2 and so on, and
data-tag; the list carries data-tags with the tags in order. Keys
that are both numbers compare as numbers, anything else as words, and a
row without the key goes last. Any other change to the list goes through
change, which runs it inside a transition where there is one.
Options
| Option | Default | Meaning |
|---|---|---|
order |
0 | As written, or by data-sort-n |
descending |
false | The order reversed |
tag |
0 | All rows, or those whose data-tag is the n-th of the list’s data-tags |
duration |
320 | How long a row takes to travel, in ms |
Versions
| Version | What it is |
|---|---|
| Reflow | The defaults: as written |
| By year | The second key, newest first |
| Games only | The first tag |
Where it must not go
- A table of data. A row that travels is a row the reader loses their place in; sort a table in place.
- Prose. Nothing in a reading column is a list to reorder.
What it does when things are missing
Without startViewTransition, or under reduced motion, the rows
change in place. Rows filtered out are hidden with the hidden
attribute and never removed, so nothing the page had is lost. A change
asked for while one is in flight waits for it. destroy() restores the
written order, shows every row and removes the style.
Learned while building it
match-elementis the whole engine. A per-row name would have let rows be staggered and would have had to be unique by hand; a name the browser derives from the element cannot clash and costs nothing to assign. There is no stagger, by that choice.
/**
* listing-reflow
*
* Rows travel to their new places when a list is sorted or filtered: a
* same-document view transition in which every row is named as itself.
*
* Copy this file. It imports nothing.
*
* import { mount } from './listing-reflow';
* const handle = mount(document.querySelector('.cards'));
* handle.update({ order: 1 }); // by data-sort-1
* handle.update({ tag: 2 }); // only rows whose data-tag is the 2nd of data-tags
* handle.change(() => list.append(row)); // any other change, inside a transition
* handle.destroy(); // the written order, every row shown
*
* The list says what it can be ordered and filtered by, in attributes;
* the recipe moves the rows. Each child may carry `data-sort-1`,
* `data-sort-2` and so on as keys, and `data-tag`; the list carries
* `data-tags` with the tags in order. Rows that are filtered out are
* hidden, never removed, and come back.
*/
export interface Options {
/** Which key the rows are ordered by: 0 as written, n the rows' data-sort-n attribute. */
order: number;
/** The order reversed. */
descending: boolean;
/** Which rows are shown: 0 all, n the rows whose data-tag is the n-th word of the list's data-tags. */
tag: number;
/** How long a row takes to travel, in ms. */
duration: number;
}
export const DEFAULTS: Options = {
order: 0,
descending: false,
tag: 0,
duration: 320,
};
/** Named versions. The first is the defaults. */
export const PRESETS: Record<string, Partial<Options>> = {
Reflow: {},
'By year': { order: 2, descending: true },
'Games only': { tag: 1 },
};
export interface Handle {
update(options: Partial<Options>): void;
/** Runs any change to the list inside a transition where there is one, with every row named as itself. */
change(mutate: () => void): void;
destroy(): void;
}
const NOOP: Handle = { update() {}, change(mutate) { mutate(); }, destroy() {} };
const clampNumber = (value: unknown, low: number, high: number, fallback: number): number =>
typeof value === 'number' && Number.isFinite(value) ? Math.min(high, Math.max(low, Math.round(value))) : fallback;
export function mergeOptions(base: Options, explicit: Partial<Options>): Options {
return {
order: clampNumber(explicit.order, 0, 99, base.order),
descending: typeof explicit.descending === 'boolean' ? explicit.descending : base.descending,
tag: clampNumber(explicit.tag, 0, 99, base.tag),
duration: clampNumber(explicit.duration, 0, 3000, base.duration),
};
}
/** Numbers as numbers, words as words, and a row without the key last. */
export function compareKeys(a: string | null, b: string | null): number {
if (a === null || b === null) return a === b ? 0 : a === null ? 1 : -1;
const x = Number(a);
const y = Number(b);
if (a.trim() !== '' && b.trim() !== '' && Number.isFinite(x) && Number.isFinite(y)) return x - y;
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
/** The order the rows go in, by their written index, and which are hidden. Stable: equal keys keep their written order. */
export function arrangement(
rows: readonly { keys: readonly (string | null)[]; tag: string | null }[],
order: number,
descending: boolean,
tag: string | null,
): { order: number[]; hidden: boolean[] } {
const indices = rows.map((_, index) => index);
if (order > 0) indices.sort((a, b) => compareKeys(rows[a]?.keys[order - 1] ?? null, rows[b]?.keys[order - 1] ?? null) || a - b);
if (descending) indices.reverse();
return { order: indices, hidden: rows.map((row) => tag !== null && row.tag !== tag) };
}
/** One mount per list. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();
export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
if (!element) return NOOP;
const doc = element.ownerDocument;
const win = doc.defaultView;
if (!win) return NOOP;
mounted.get(element)?.destroy();
let current = mergeOptions(DEFAULTS, options);
// The one place motion is decided, so a host can override matchMedia.
const reduce = (): boolean => typeof win.matchMedia === 'function' && win.matchMedia('(prefers-reduced-motion: reduce)').matches;
const rows = Array.from(element.children).filter((child): child is HTMLElement => child instanceof HTMLElement);
const style = doc.createElement('style');
style.dataset.listingReflow = '';
style.textContent = `::view-transition-group(.reflow) { animation-duration: ${current.duration}ms; animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }`;
doc.head.appendChild(style);
let busy = false;
let queued: (() => void) | null = null;
const named: HTMLElement[] = [];
const clear = (): void => {
for (const row of named.splice(0)) {
row.style.removeProperty('view-transition-name');
row.style.removeProperty('view-transition-class');
}
};
const change = (mutate: () => void): void => {
if (busy) {
// One at a time: the last change asked for runs when this one lands.
queued = mutate;
return;
}
if (reduce() || typeof doc.startViewTransition !== 'function') {
mutate();
return;
}
busy = true;
for (const row of rows) {
row.style.setProperty('view-transition-name', 'match-element');
row.style.setProperty('view-transition-class', 'reflow');
named.push(row);
}
const done = (): void => {
clear();
busy = false;
const next = queued;
queued = null;
if (next) change(next);
};
try {
doc.startViewTransition(mutate).finished.then(done, done);
} catch {
mutate();
done();
}
};
const apply = (): void => {
const tags = (element.dataset.tags ?? '').split(/\s+/).filter(Boolean);
const tag = current.tag > 0 ? (tags[current.tag - 1] ?? null) : null;
const keys = Math.max(current.order, 0);
const laid = arrangement(
rows.map((row) => ({ keys: Array.from({ length: keys }, (_, index) => row.getAttribute(`data-sort-${index + 1}`)), tag: row.dataset.tag ?? null })),
current.order,
current.descending,
tag,
);
change(() => {
laid.hidden.forEach((hidden, index) => {
const row = rows[index];
if (row) row.hidden = hidden;
});
element.append(...laid.order.map((index) => rows[index]).filter((row): row is HTMLElement => row !== undefined));
});
};
let destroyed = false;
const handle: Handle = {
update(next) {
current = mergeOptions(current, next);
style.textContent = `::view-transition-group(.reflow) { animation-duration: ${current.duration}ms; animation-timing-function: cubic-bezier(0.2, 0, 0, 1); }`;
apply();
},
change,
destroy() {
if (destroyed) return;
destroyed = true;
queued = null;
clear();
for (const row of rows) row.hidden = false;
element.append(...rows);
style.remove();
if (mounted.get(element) === handle) mounted.delete(element);
},
};
mounted.set(element, handle);
if (current.order !== DEFAULTS.order || current.tag !== DEFAULTS.tag || current.descending) apply();
return handle;
}