Frameworks
JavaScript
The package root, @ogdakke/fluidbox, runs the viewer without a framework or custom elements. createFluidbox() makes a fluidbox, which is one viewer at the end of <body> plus the state, commands and events that drive it. fluidbox.bind() makes an element open it.
Install#
pnpm add @ogdakke/fluidboxnpm install @ogdakke/fluidboxyarn add @ogdakke/fluidboxbun add @ogdakke/fluidboximport { createFluidbox } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/styles.css";Import @ogdakke/fluidbox/styles.css once in your app. The filmstrip and the sheet are add-ons, covered in Add-ons.
A gallery#
import { createFluidbox, type FluidboxItem } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/styles.css";
const photos: FluidboxItem[] = [
{
type: "img",
src: "/photos/dune.jpg",
alt: "Dune at dusk",
width: 1800,
height: 1200,
thumbnail: { src: "/photos/dune-400.jpg", width: 400, height: 267 },
},
{
type: "img",
src: "/photos/fjord.jpg",
alt: "Fjord",
width: 1200,
height: 1600,
thumbnail: { src: "/photos/fjord-400.jpg", width: 400, height: 533 },
},
];
const fluidbox = createFluidbox({ items: photos });
// Each <button> in .gallery holds its thumbnail <img>, in the order of `photos`.
document.querySelectorAll<HTMLButtonElement>(".gallery button").forEach((button, index) => {
fluidbox.bind(button, index);
});The viewer has Previous, Next and Close buttons until you draw your own with viewer. To keep some of them next to yours, pass controls: true or a list such as ["close"]. The same gallery, live, in every framework:
Loading the live example…
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/react";
import { photos } from "./data";
export function Basic() {
return (
<Fluidbox.Root items={photos}>
<div className="pattern-grid">
{photos.map((photo) => (
<Fluidbox.Trigger
key={photo.src}
item={photo}
className="pattern-thumb"
>
<img src={photo.src} alt={itemLabel(photo)} />
</Fluidbox.Trigger>
))}
</div>
{/* Without a Viewer, the viewer has these same three buttons. */}
<Fluidbox.Viewer>
<Fluidbox.Previous />
<Fluidbox.Next />
<Fluidbox.Close />
</Fluidbox.Viewer>
</Fluidbox.Root>
);
}<script setup lang="ts">
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/vue";
import { photos } from "./data";
</script>
<template>
<!-- Without a Viewer, the viewer has the default Previous, Next and Close. -->
<Fluidbox.Root :items="photos">
<div class="pattern-grid">
<Fluidbox.Trigger
v-for="photo in photos"
:key="photo.id"
:item="photo"
class="pattern-thumb"
>
<img :src="photo.src" :alt="itemLabel(photo)" />
</Fluidbox.Trigger>
</div>
</Fluidbox.Root>
</template>import { For } from "solid-js";
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/solid";
import { photos } from "./data";
export function Basic() {
return (
<Fluidbox.Root items={photos}>
<div class="pattern-grid">
<For each={photos}>
{(photo) => (
<Fluidbox.Trigger item={photo} class="pattern-thumb">
<img src={photo.src} alt={itemLabel(photo)} />
</Fluidbox.Trigger>
)}
</For>
</div>
{/* Without a Viewer, the viewer has these three by default. */}
<Fluidbox.Viewer>
<Fluidbox.Previous />
<Fluidbox.Next />
<Fluidbox.Close />
</Fluidbox.Viewer>
</Fluidbox.Root>
);
}import { Component } from "@angular/core";
import { itemLabel } from "@ogdakke/fluidbox";
import { FluidboxModule } from "@ogdakke/fluidbox/angular";
import { photos } from "./data";
@Component({
selector: "pattern-basic",
standalone: true,
imports: [FluidboxModule],
template: `
<div fluidboxRoot [items]="photos" class="pattern-grid">
@for (photo of photos; track photo.id) {
<button [fluidboxTrigger]="photo" class="pattern-thumb">
<img [src]="photo.src" [alt]="label(photo)" />
</button>
}
<!-- Without a viewer template these three are the default; with one, you place them. -->
<ng-template fluidboxViewer>
<button fluidboxPrevious></button>
<button fluidboxNext></button>
<button fluidboxClose></button>
</ng-template>
</div>
`,
})
export class Basic {
readonly photos = photos;
readonly label = itemLabel;
}<fluidbox-gallery class="pattern-grid">
<fluidbox-item item-id="photo-aurora">
<button slot="trigger" type="button" class="pattern-thumb">
<img src="/art/aurora.svg" alt="Aurora over the tundra" />
</button>
<template slot="content">
<img src="/art/aurora.svg" alt="Aurora over the tundra" width="1100" height="1650" />
</template>
</fluidbox-item>
<fluidbox-item item-id="photo-ridge">
<button slot="trigger" type="button" class="pattern-thumb">
<img src="/art/ridge.svg" alt="Night ridge" />
</button>
<template slot="content">
<img src="/art/ridge.svg" alt="Night ridge" width="1200" height="1500" />
</template>
</fluidbox-item>
<fluidbox-item item-id="photo-fjord">
<button slot="trigger" type="button" class="pattern-thumb">
<img src="/art/fjord.svg" alt="Fjord in the blue hour" />
</button>
<template slot="content">
<img src="/art/fjord.svg" alt="Fjord in the blue hour" width="1200" height="1600" />
</template>
</fluidbox-item>
<fluidbox-item item-id="photo-lagoon">
<button slot="trigger" type="button" class="pattern-thumb">
<img src="/art/lagoon.svg" alt="Lagoon after sunset" />
</button>
<template slot="content">
<img src="/art/lagoon.svg" alt="Lagoon after sunset" width="1800" height="1200" />
</template>
</fluidbox-item>
</fluidbox-gallery>import { createFluidbox, itemLabel } from "@ogdakke/fluidbox";
import { photos } from "./data";
export function mountBasic(root: HTMLElement) {
// The viewer has Previous, Next and Close buttons unless you draw your own.
const fluidbox = createFluidbox({ items: photos });
const grid = document.createElement("div");
grid.className = "pattern-grid";
photos.forEach((photo, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "pattern-thumb";
button.append(Object.assign(new Image(), { src: photo.src, alt: itemLabel(photo) }));
grid.append(button);
fluidbox.bind(button, index);
});
root.append(grid);
return () => {
// Also unbinds the buttons.
fluidbox.destroy();
grid.remove();
};
}createFluidbox(options)#
Returns a Fluidbox. Only items is required.
| Option | Type | Default | What it does |
|---|---|---|---|
items |
readonly TItem[] | FluidboxItemList<TItem> |
The items: an array, or a lazy list { count, get, indexOf? }. See Items and origins. |
|
controls |
boolean | readonly ("previous" | "next" | "close")[] |
true without viewer, else false |
The default Previous, Next and Close buttons, or some of them. |
viewer |
({ target, fluidbox, signal }) => void | (() => void) |
Draw your own controls and captions into the viewer. See Viewer content. | |
renderers |
Record<string, FluidboxRenderer<TItem>> |
{} |
Draw custom items, by the name in their render. See Media types. |
zoom |
false | { step?: number; max?: number } |
{ step: 2, max: 4 } |
step is the scale of a double-tap or double-click; max the largest scale. false turns zoom off. |
loop |
boolean |
false |
Go round: past the last item comes the first, by swipe, key, button and filmstrip. A single item never loops. update changes it live. |
labels |
Partial<FluidboxLabels> |
English | Your strings for the viewer's names and announcements. See Labels. |
filmstrip |
boolean | { render? } |
false |
A thumbnail strip. Loads its entry; needs filmstrip.css. |
sheet |
boolean | { snapPoints?, dismiss?, render? } |
false |
A panel over the photo. Loads its entry; needs sheet.css. |
origins |
FluidboxOriginAdapter<TItem> |
Finds the triggers of items you never bound, as in a virtualized grid. See The origin adapter. | |
loading |
"hover" | "press" | "eager" |
"hover" |
When the viewer's code loads. See Loading the viewer. |
To change labels, zoom, controls, viewer, filmstrip, sheet or loading, call update. The open viewer stays open. To change the items, call setItems. The fluidbox looks up renderers by name each time a custom slide mounts, and reads origins on each use.
The fluidbox#
| Member | Returns | What it does |
|---|---|---|
state |
FluidboxState<TItem> |
The current state, a frozen snapshot. See State. |
items |
FluidboxItemList<TItem> |
The items the viewer reads, as a list: the ones it was made with, or the last setItems. |
open(target?, options?) |
Promise<boolean> |
Opens the viewer on an item, or moves to it while open. Without a target: the current item, else the first. |
close(options?) |
Promise<boolean> |
Closes the viewer. |
next(options?) |
Promise<boolean> |
Moves to the next item while open. |
previous(options?) |
Promise<boolean> |
Moves to the previous item while open. |
update(options) |
void |
Changes labels, zoom, loop, controls, viewer, filmstrip, sheet or loading live. See update. |
setItems(items) |
Promise<boolean> |
Replaces the items. An open viewer stays on the same id when it is still there. |
preload(target) |
() => void |
Starts loading an item a press is likely to open, while the viewer is closed. Call the result to let go. |
load() |
Promise<boolean> |
Loads the viewer's code now. See Loading the viewer. |
bind(element, target, options?) |
() => void |
Makes an element a trigger. See Triggers. |
bindOverlay(element, target, options?) |
() => void |
Makes an element on a trigger a thumbnail overlay. See Thumbnail overlays. |
subscribe(listener) |
() => void |
Calls listener(state) after every state change. |
sheet |
FluidboxSheet | null |
The sheet, when the sheet option is on. |
destroy() |
void |
Closes at once, removes the viewer, and releases every bind and bindOverlay. Safe to call twice. |
A target is an item, its index or its id. The fluidbox matches an item object by its id, so a copy of it works too. The fluidbox is an EventTarget, with the events listed below.
update#
update(options) changes options on the live viewer, without closing it. A key you leave out keeps its value; a key set to undefined goes back to its default:
const coarse = matchMedia("(pointer: coarse)");
coarse.addEventListener("change", () => {
fluidbox.update({ zoom: coarse.matches ? false : undefined });
});
fluidbox.update({ labels: { close: "Sulje", previous: "Edellinen", next: "Seuraava" } });Labels and zoom apply in place. A change of controls, viewer or the labels draws the viewer's layer again, and a change of filmstrip or sheet mounts that add-on again. update compares options objects member by member and functions by identity, so pass the same viewer and render functions from one call to the next.
Commands#
A target, FluidboxTarget, is an item, its index or its id: open(photo), open(2) or open("photo-fjord"). options is { signal?: AbortSignal, scrub?: boolean }. Use scrub: true for a move that follows a continuous input, such as your own scrubber or a dragged row of dots. The slide jumps to the item instead of gliding. The fluidbox doesn't scroll the page to the item's trigger until the next move without scrub, which ends the scrub.
Each command returns a promise that settles when the motion has: open once the viewer is open, close once it is closed, next once the slide has moved. It resolves true when the command happened. It resolves false when a listener prevented it, a later command took over, its signal aborted, or there was nothing to do, as with next() on the last item of a gallery that doesn't loop, or close() while closed.
An unknown id or an index out of range rejects with a RangeError, and preload throws one. After destroy(), the commands and load() resolve false, so a part of your app still holding the old fluidbox does nothing. setItems, preload, bind, bindOverlay and subscribe throw an InvalidStateError DOMException.
State#
interface FluidboxState<TItem> {
readonly status: "closed" | "opening" | "open" | "closing";
readonly id: string | null;
readonly index: number | null;
readonly item: TItem | null;
readonly count: number;
readonly hasPrevious: boolean;
readonly hasNext: boolean;
}id, index and item are the item the viewer shows, or is on its way to while a move runs, and null while closed. item is your own object, the one you passed in. hasPrevious and hasNext say whether previous() and next() have somewhere to go. With loop, both are true in a gallery of two or more. subscribe sees status and selection changes. It doesn't see motion, which runs frame by frame in CSS variables. Styling lists them.
Events#
Listen with addEventListener. The events are typed, so event.reason and event.item need no casts:
| Event | Class | Fields | When |
|---|---|---|---|
beforetoggle |
FluidboxToggleEvent |
oldState, newState, reason |
Before the viewer opens or closes. Cancelable: preventDefault() keeps it as it is. |
toggle |
FluidboxToggleEvent |
oldState, newState, reason |
The opening or closing motion starts. |
beforeitemchange |
FluidboxItemChangeEvent |
id, index, item, reason |
Before the open viewer moves to another item. Cancelable: preventDefault() stays put. |
itemchange |
FluidboxItemChangeEvent |
id, index, item, reason |
The viewer moves to an item, including the one it opens on. |
settle |
FluidboxItemChangeEvent |
id, index, item, reason |
The viewer comes to rest on an item. |
sheetchange |
FluidboxSheetEvent |
stop, reason |
The sheet comes to rest at another stop. |
oldState and newState are "open" or "closed", as on a popover's toggle events. reason, a FluidboxReason, says what asked:
| Reason | From |
|---|---|
"api" |
Your code: a command, or a default control. |
"trigger" |
A click on a trigger. |
"keyboard" |
The arrow keys and Escape. The page-scrolling keys close the viewer with this too. |
"pointer" |
A swipe or scroll through the slides, a drag to dismiss, a tap on the backdrop. |
"wheel" |
A wheel or trackpad scroll that dismisses the viewer. |
"items" |
setItems, when the new list moved the viewer to another item. |
The before-events fire for every command that would do something, whether it comes from a key, a gesture or your code. close() while closed or next() on the last item fires nothing. An open fires beforetoggle, toggle and itemchange, then settle after the flight. Then the promise resolves.
fluidbox.addEventListener("beforeitemchange", (event) => {
if (event.reason === "wheel") event.preventDefault();
});
fluidbox.addEventListener("settle", ({ id }) => {
history.replaceState(null, "", `#${id}`);
});Triggers#
fluidbox.bind(element, target, options?) makes an element a trigger. It registers the element as its item's origin, so the flight starts and lands there. It loads the viewer's code as loading says, starts loading the item on pointer down, and opens the viewer on click unless a listener prevented the click. It adds data-fluidbox-trigger, aria-haspopup="dialog" and aria-expanded to the element. Use an element that can take focus, such as a <button>. It returns a function that unbinds.
| Option | Type | What it does |
|---|---|---|
signal |
AbortSignal |
Unbinds when it aborts. |
returns |
FluidboxTarget<TItem>[] |
More items that close back onto the trigger. See One trigger for several items. |
onPreviewChange |
(item: TItem, index: number) => void |
Called before the closing flight measures the trigger, with the item it lands on. Show that item on the trigger, synchronously. |
The flight uses the trigger's first image, or an element inside it marked data-fluidbox-origin. Which element the flight uses has the rules.
One trigger for several items#
List more items in returns, and one element stands for all of them, as an album cover does. The viewer opens on the target, and the target and every item in returns close back onto the element. returns may hold the whole album, the target included. After a close, a click opens the item the trigger last showed, so the flight starts from the picture on it:
fluidbox.bind(cover, album[0], {
returns: album,
onPreviewChange(item) {
coverImage.src = item.thumbnail?.src ?? coverImage.src;
},
});One trigger for an album shows it in every framework.
Bindings follow setItems. An id that leaves the list stops being the trigger's until it comes back, and an index names whichever item is at that index now, so bind by id where the list reorders.
Thumbnail overlays#
A badge, a play icon or a count on a thumbnail can follow the flight. fluidbox.bindOverlay(element, target, options?) makes an element inside a trigger a thumbnail overlay. As the viewer opens and closes, the element gets --fluidbox-closed, from 1 at the thumbnail to 0 fully open, and data-fluidbox-phase. Without travel, it stays on the page.
| Option | Type | What it does |
|---|---|---|
signal |
AbortSignal |
Unbinds when it aborts. |
returns |
FluidboxTarget<TItem>[] |
More items whose flights carry the overlay, as with the trigger's returns. |
travel |
boolean | FluidboxRenderer<TItem> |
Fly with the photo. true flies a copy of the element's content; a renderer draws live content into the flying frame. |
frameLayer |
HTMLElement |
With travel, an element moved into the flying frame for the flight and put back after. |
// The count flies with the photo and fades as it lands.
fluidbox.bindOverlay(count, album[0], { returns: album, travel: true });
// Live content: the renderer draws into the flying frame for each flight.
fluidbox.bindOverlay(badge, "clip", {
travel: ({ item, target }) => {
const label = document.createElement("span");
label.className = "badge";
label.textContent = item.type === "video" ? "Video" : "";
target.append(label);
},
});The travelling content and the frameLayer scale with the flying frame, so their boxes match it. Their direct children scale back, so they keep the size they had on the thumbnail. A child scales back around the edge it sits against (left, right, top or bottom) or around its centre, so a corner badge stays in its corner and a centred label stays centred. Size children by their content. A child stretched to the box's width ends up as wide as the full-size frame. While the flight starts, the page shows a still copy of the frameLayer, marked data-fluidbox-ghost, in the thumbnail.
The renderer gets { item, index, target, signal }, like any renderer. See Styling for the CSS hooks.
Viewer content#
The viewer's layer is yours to fill. The viewer option draws into it when the fluidbox is made, and again after an update that changes the layer:
| Context field | What it is |
|---|---|
target |
The viewer's layer, [data-fluidbox-portal]. |
fluidbox |
The fluidbox. Use it here, since your own variable isn't assigned yet. |
signal |
Aborts when the fluidbox is destroyed, or the layer is redrawn. |
Return a function to clean up, such as the one subscribe returns. Everything in the layer hides during the opening flight and fades out as a close or a drag starts, like the default controls. controls and viewer combine, with the default buttons first and yours after.
import { createFluidbox } from "@ogdakke/fluidbox";
const fluidbox = createFluidbox({
items: photos,
controls: ["close"],
viewer({ target, fluidbox }) {
const caption = document.createElement("p");
caption.className = "caption";
target.append(caption);
return fluidbox.subscribe(({ item }) => {
if (item?.type === "img") caption.textContent = item.alt;
});
},
});The default buttons carry data-fluidbox-previous, data-fluidbox-next and data-fluidbox-close. Any button in the viewer with one of them runs that command, gets the default styling, and is disabled while the command can do nothing. Call event.preventDefault() in your own click listener to stop the command. Headless controls has a full caption bar in every framework.
Add-ons#
The filmstrip and the sheet have their own entries. Turn one on with its option and the fluidbox loads its entry; import the entry to bundle it up front:
import { createFluidbox } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/filmstrip";
import "@ogdakke/fluidbox/filmstrip.css";
import "@ogdakke/fluidbox/sheet";
import "@ogdakke/fluidbox/sheet.css";
const fluidbox = createFluidbox({
items: photos,
filmstrip: true,
sheet: {
snapPoints: ["40%", "90%"],
render({ item, target }) {
target.textContent = item.id ?? "";
},
},
});An open waits for an entry that is still loading. Filmstrip and sheet has every option.
Patterns#
| Pattern | In plain JavaScript |
|---|---|
| Gallery | createFluidbox({ items }) and fluidbox.bind(button, index). |
| One trigger for an album | bind(cover, album[0], { returns, onPreviewChange }), and bindOverlay(count, ...). |
| Rich origins | data-fluidbox-origin inside the trigger. |
| Custom content | renderers: { note: ({ target }) => {} } and render: "note" on the item. |
| Programmatic control | open(2), subscribe, and beforeitemchange with preventDefault(). |
| Zoom | zoom: { step, max } or zoom: false. |
| Filmstrip | filmstrip: true, or filmstrip: { render }. |
| Sheet | sheet: { snapPoints, render }, fluidbox.sheet.set(stop) and sheetchange. |
| Headless controls | A viewer renderer that builds your buttons and subscribes to the state. |
| Right to left | A dir="rtl" ancestor of the triggers, and the labels option. |
| Photo in the address | open() and close() from ?photo=, and toggle and itemchange writing it back. |
Notes#
Server rendering. Importing @ogdakke/fluidbox has no side effects and touches no browser globals, so it is safe on the server. createFluidbox() needs document.
Clean up. Call destroy() when the gallery leaves the page. It releases every binding too, so the functions bind returns are for unbinding one trigger early.
Your own fields. Items can carry fields of your own: createFluidbox<Photo>({ items }) with interface Photo extends FluidboxImageItem { caption: string }. Every callback, event and state hands back your object, so state.item.caption is typed.