Guides

Items and origins

Fluidbox keeps two things apart. The items say what the viewer can show. The origins say where each item sits on the page, so the opening flight can start there and the closing flight can land there.

Items#

An item is a plain object: { type: "img", src, alt, width, height }, a video, an iframe, or custom content. Media types covers every field. Each item is known by its id, which defaults to its src. idOf(item) from the package root returns it. Events report ids and triggers register them, so they must be unique in a list. Commands and triggers take the item itself, its index or its id. The viewer matches an item by its id, so a fresh copy of it works too.

The viewer takes its items as an array or as a lazy list. Pass them as the items option of createFluidbox, the items prop of each framework root, or the items property of <fluidbox-gallery>.

Arrays#

For a list you already have in memory, pass the array:

ts
import { createFluidbox } from "@ogdakke/fluidbox";

const fluidbox = createFluidbox({ items: photos });

The viewer copies the array, so later changes to photos don't reach it. It throws a TypeError for an item with neither an id nor a src, and for a duplicate id. An empty array is a valid, empty gallery.

Lazy lists#

When the items come from a database, an API or a formula, make a lazy list with createItemList. You give it a count and a function that makes the item at an index.

ts
import { createItemList } from "@ogdakke/fluidbox";

export const library = createItemList({
  count: 1000,
  item: (index) => ({
    type: "img",
    id: `photo-${index}`,
    src: `/photos/${index}.jpg`,
    alt: `Photo ${index + 1}`,
    width: 1600,
    height: 1200,
    thumbnail: { src: `/photos/${index}-thumb.jpg`, width: 320, height: 240 },
  }),
  index: (id) => Number(id.slice("photo-".length)),
});
  • item(index) runs once per index. The list keeps what it returns, so state.item and every event get the same object for the same item, and your effects and memos keyed on it don't run again.
  • index(id) says where an id is, and it can be a rough guess. The list ignores an index out of range, and an index whose item has a different id, so "photo-01" and "photo-x" find nothing. Leave it out and the list scans for ids, which is fine for a few hundred items and slow for a hundred thousand.
  • The list is a snapshot. To change the items, make a new list and pass it in.

createItemList returns a plain FluidboxItemList. You can also write one yourself, when you need control the factory doesn't give:

ts
interface FluidboxItemList<TItem> {
  readonly count: number;
  get(index: number): TItem;
  indexOf?(id: string): number;
}
  • count is a non-negative integer. The viewer throws a RangeError otherwise.
  • get(index) returns the item at an index from 0 to count - 1, synchronously, and the same object each time.
  • indexOf(id) returns the index of an id, or -1. Without it, the viewer finds an id by calling get for each index in turn.
  • Don't change a list the viewer holds.

The viewer asks for items only as it needs them: the slides near the current one, and the filmstrip thumbnails in view. The viewer checks only arrays for duplicate ids, so a lazy list has to keep its own ids unique. Render triggers for the items you show, such as the rows of a virtual list. The viewer can still open, and swipe through, every item.

Replacing the items#

Pass new items with fluidbox.setItems(items), a new items prop in a framework, or a new items property on the gallery. While the viewer is open:

  • If the current item's id is still in the new list, the viewer stays on it, even if its index moved.
  • If it isn't, the viewer moves to the item now at the same index, or the last one.
  • If the new list is empty, the viewer closes.

A move fires itemchange and settle with the reason "items". A command still running when the items change resolves false.

Origins#

An origin is where an item lives on the page, FluidboxOrigin<TItem>:

Field Type What it does
trigger HTMLElement The element that opens the item. Focus returns here when the viewer closes.
visual HTMLElement The box the flight starts and ends on. The flight copies its size, crop and corner radius.
thumbnail HTMLImageElement Optional. An image already loaded on the page, shown while the full media loads.
onPreviewChange (item: TItem, index: number) => void Optional. Called before the closing flight measures the origin, with the item it lands on.

You rarely build one by hand. Every trigger (fluidbox.bind, the framework Trigger parts, the [slot="trigger"] child of <fluidbox-item>) makes its element the origin of its items while it is bound.

Which element the flight uses#

For a trigger, the viewer picks the visual in this order:

  1. The trigger itself or a descendant, marked data-fluidbox-origin.
  2. The trigger itself if it is an <img> or <video>, else its first <img> or <video>.
  3. The trigger.

The flight's thumbnail is the visual if it is an <img>, else the first <img> inside it. Mark the element that clips the picture. If a rounded wrapper crops a square image, mark the wrapper, or the flight lands with square corners:

html
<button type="button" class="card">
  <span data-fluidbox-origin class="card-photo">
    <img src="/shoe-thumb.jpg" alt="Coral running shoe" />
  </span>
  <span>Coral runner, €120</span>
</button>

Rich origins has this card in every framework.

No origin#

The closing flight needs an origin that is in the document and visible inside the window and every scrolling ancestor. Without one, the viewer shrinks the picture a little and fades it out where it is. With prefers-reduced-motion: reduce, the viewer opens and closes without flights at all.

Several items, one origin#

One element can stand for several items, such as an album cover with a "+4" badge. The trigger opens its own item, and returns lists the other items that may close back onto it. Set it as returns in fluidbox.bind, on the framework triggers (fluidboxReturns in Angular), or as returns="b c" on a <fluidbox-item>. returns may hold the trigger's own item too, so a whole album works as is. The origin's onPreviewChange runs before the closing flight measures it, so the cover can show the item it lands on:

ts
fluidbox.bind(cover, "dune", {
  returns: ["canyon", "coast"],
  onPreviewChange(item) {
    coverImage.src = item.thumbnail?.src ?? coverImage.src;
  },
});

An origin bound for one id wins over one bound for several. Both win over the adapter below. One trigger for an album shows a cover whose image follows the selection.

The origin adapter#

Triggers cover items that have a bound element. For the rest, as in a virtualized grid that recycles its rows, give the viewer an origins adapter, FluidboxOriginAdapter:

ts
interface FluidboxOriginAdapter<TItem extends FluidboxItem = FluidboxItem> {
  getOrigin(id: string): FluidboxOrigin<TItem> | null;
  revealOrigin?(
    id: string,
    index: number,
    context: { signal: AbortSignal; phase: "open" | "selection" },
  ): void | Promise<void>;
}

The viewer calls getOrigin whenever it needs the origin of an item no trigger is bound to. Return null when the item isn't on the page. A virtualizer may reuse a node for another item, so look the element up by id on each call instead of caching it.

ts
import { createFluidbox } from "@ogdakke/fluidbox";

const fluidbox = createFluidbox({
  items: library,
  origins: {
    getOrigin(id) {
      const button = grid.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`);
      const image = button?.querySelector("img");
      return button && image ? { trigger: button, visual: image, thumbnail: image } : null;
    },
  },
});

The framework roots take the same object as an origins prop, and <fluidbox-gallery> as its origins property. They read the newest one on each call, so changing it doesn't rebuild the viewer.

Reveal on close#

The viewer brings the current item's origin into view, so the closing flight has somewhere to land. It does this after the viewer opens, each time the selection settles, and when it closes on an item it hasn't revealed yet.

Without revealOrigin, the viewer scrolls the item's origin into view, to the nearest edge, in every scrolling ancestor. That origin is the item's bound trigger, or the one getOrigin returns. In a horizontal scroller the strip follows the viewer, so the flight lands on the right thumbnail.

With revealOrigin, you do the scrolling. Scroll a virtual list to the item's row so its trigger mounts:

ts
import type { FluidboxOriginAdapter } from "@ogdakke/fluidbox";

const origins: FluidboxOriginAdapter = {
  getOrigin,
  async revealOrigin(id, index, { signal }) {
    virtualizer.scrollToIndex(index, { align: "auto" });
    await mounted(id, signal);
  },
};
  • phase is "open" after an opening and "selection" after a move.
  • The viewer's motion never waits for the promise. When it resolves, the viewer hides the item's thumbnail on the page, so the picture doesn't show twice.
  • signal aborts when the selection moves on, the viewer closes, the items change or the fluidbox is destroyed. Check it before late work.
  • The viewer ignores errors thrown here.

Custom elements#

A <fluidbox-gallery> builds its items from its <fluidbox-item> children. For an array or a lazy list made in JavaScript, set gallery.items instead. Mark any element inside the gallery with data-fluidbox-item-id to make it a trigger for that item:

ts
import "@ogdakke/fluidbox/elements";
import { library } from "./library";

const gallery = document.querySelector("fluidbox-gallery")!;
gallery.items = library;

for (let index = 0; index < 12; index++) {
  const item = library.get(index);
  const button = document.createElement("button");
  button.type = "button";
  button.dataset.fluidboxItemId = item.id!;
  button.append(Object.assign(new Image(), { src: item.thumbnail!.src, alt: "" }));
  gallery.append(button);
}

With items set, the gallery ignores its <fluidbox-item> children. See Custom elements.