Frameworks
React
@ogdakke/fluidbox/react builds a gallery from parts: Fluidbox.Root owns one viewer for its items, Fluidbox.Trigger opens it, and Fluidbox.Viewer renders your own controls into it. It needs react and react-dom 18 or later.
Install#
pnpm add @ogdakke/fluidboxnpm install @ogdakke/fluidboxyarn add @ogdakke/fluidboxbun add @ogdakke/fluidboximport { Fluidbox, useFluidbox, useFluidboxSheet } from "@ogdakke/fluidbox/react";
import "@ogdakke/fluidbox/styles.css";Import @ogdakke/fluidbox/styles.css once in your app. The filmstrip and the sheet are parts of the binding. Rendering Fluidbox.Filmstrip or Fluidbox.Sheet turns it on. They also need their stylesheets, @ogdakke/fluidbox/filmstrip.css and @ogdakke/fluidbox/sheet.css.
A gallery#
import type { FluidboxImageItem } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/react";
import "@ogdakke/fluidbox/styles.css";
const photos: FluidboxImageItem[] = [
{
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 },
},
];
export function Photos() {
return (
<Fluidbox.Root items={photos}>
{photos.map((photo) => (
<Fluidbox.Trigger key={photo.src} item={photo}>
<img src={photo.thumbnail!.src} alt={photo.alt} />
</Fluidbox.Trigger>
))}
</Fluidbox.Root>
);
}Without a Fluidbox.Viewer, the viewer has the default Previous, Next and Close buttons. With one, it has what you put in it. 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();
};
}Parts#
All parts are properties of the Fluidbox object.
| Part | Renders | Role |
|---|---|---|
Fluidbox.Root |
nothing of its own | Owns one viewer for its items. Every other part must be inside it. |
Fluidbox.Trigger |
a <button>, or its child |
Opens an item. Registers itself as the item's origin. |
Fluidbox.ThumbnailOverlay |
a <span> |
A badge or caption on the thumbnail that fades or flies with the photo. |
Fluidbox.FrameLayer |
a <span> |
Goes inside a travelling ThumbnailOverlay. Moves into the flying frame and scales with it. |
Fluidbox.Viewer |
its children, in the viewer | Your controls and captions, in place of the default controls. |
Fluidbox.Controls |
three <button>s |
The default Previous, Next and Close, for a Viewer of your own. |
Fluidbox.Previous, Fluidbox.Next, Fluidbox.Close |
a <button> |
Run the command, with the default icon and label, and disable themselves when they can't. |
Fluidbox.Filmstrip |
its children, in each thumbnail | Turns the filmstrip on. A render child draws your own thumbnails. |
Fluidbox.Sheet |
its children, in the sheet | Turns the sheet on and renders its content for the item on show. |
Fluidbox.SheetStop |
a <button> |
Moves the sheet to a stop. |
Fluidbox.Root#
| Prop | Type | Default | Purpose |
|---|---|---|---|
items |
readonly TItem[] | FluidboxItemList<TItem> |
The gallery's items, or a list read on demand (see Items and origins). | |
open |
boolean |
Whether the viewer is open, when your state owns it. It opens on index and closes as this changes. Leave it out to let the triggers own it. |
|
index |
number |
The item the viewer shows, when your state owns it. An index out of range is ignored. | |
labels |
Partial<FluidboxLabels> |
English | Every string the viewer shows or announces. |
zoom |
false | { step?: number; max?: number } |
{ step: 2, max: 4 } |
Zoom steps, or false to turn 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. |
renderers |
Record<string, (ctx: { item, index, signal }) => ReactNode> |
{} |
Renders { type: "custom", render: key } items. The type is FluidboxReactRenderer. |
origins |
FluidboxOriginAdapter<TItem> |
triggers | Finds origins for items without a mounted trigger, and revealOrigin scrolls one into view. |
loading |
"hover" | "press" | "eager" |
"hover" |
When the viewer's code loads. See Loading the viewer. |
onBeforeToggle |
(event: FluidboxToggleEvent) => void |
Before the viewer opens or closes. event.preventDefault() stops it. |
|
onToggle |
(event: FluidboxToggleEvent) => void |
As the viewer starts to open or close. oldState, newState and reason. |
|
onBeforeItemChange |
(event: FluidboxItemChangeEvent<TItem>) => void |
Before the viewer moves to another item. event.preventDefault() stops it. |
|
onItemChange |
(event: FluidboxItemChangeEvent<TItem>) => void |
When the active item changes, opening included. id, index, item and reason. |
|
onSettle |
(event: FluidboxItemChangeEvent<TItem>) => void |
When the active item comes to rest. | |
onSheetChange |
(event: FluidboxSheetEvent) => void |
When the sheet comes to rest at another stop. stop and reason. |
|
onOpenChange |
(open: boolean, details: { reason }) => void |
As the viewer opens or closes, from any input. Set open from it. |
|
onIndexChange |
(index: number, details: { reason }) => void |
As the viewer moves to another item, opening included. Set index from it. |
|
children |
ReactNode |
Your page content and the other parts. |
TItem flows from items to the renderers and the events. They get your own item objects back, with any fields you added.
A change to labels, zoom, or the Viewer, Filmstrip and Sheet parts and their props goes to the viewer through update, even while it is open. Root makes the viewer once per mount.
Fluidbox.Trigger#
| Prop | Type | Default | Purpose |
|---|---|---|---|
item |
FluidboxTarget<TItem> |
Required. The item to open: the item, its id or its index. | |
returns |
readonly FluidboxTarget<TItem>[] |
More items that close back onto this trigger, as for an album cover. item always does. |
|
asChild |
boolean |
false |
Decorate the single child element instead of rendering a <button>. |
children |
ReactNode | (preview: FluidboxTriggerPreview) => ReactNode |
The trigger's content, or a function of the preview that follows the viewer. | |
| ... | button attributes | Passed to the button, or to the child with asChild. The rendered button gets type="button" unless you set type. |
An item's id is its id, else its src. The render function gets a FluidboxTriggerPreview: item: TItem | null, index: number | null and status. item is the item the trigger shows. While the viewer is open, that is the item the viewer is on. After it closes, it is the last one the trigger showed, and the trigger opens on that item again. It is null when the trigger's items have left the list.
The trigger adds data-fluidbox-trigger, aria-haspopup="dialog" and aria-expanded to its element. It starts loading the item on pointer down, so the click opens without waiting.
Fluidbox.ThumbnailOverlay#
| Prop | Type | Default | Purpose |
|---|---|---|---|
travel |
boolean |
false |
Fly with the photo. Its children render live into the flying frame, with the app's context. |
| ... | span attributes | ref is forwarded. |
It gets --fluidbox-closed and data-fluidbox-phase as the viewer opens and closes, for the enclosing Trigger's item and returns. Put it inside a Trigger. Outside one, it throws.
Fluidbox.FrameLayer#
Takes span attributes and forwards ref. It renders a <span data-fluidbox-frame-layer> inside a travelling ThumbnailOverlay. On each flight, that element itself moves into the flying frame, scales with it, and goes back after. Use it for a scrim or a gradient. The overlay's live copy in the frame leaves it out, so it never shows twice.
<Fluidbox.ThumbnailOverlay travel className="badge">
<Fluidbox.FrameLayer className="scrim" />+{album.length - 1}
</Fluidbox.ThumbnailOverlay>Fluidbox.Viewer and Fluidbox.Controls#
Fluidbox.Viewer takes only children, and renders them into the viewer's layer for controls and captions. It renders nothing until the viewer exists. Fluidbox.Controls takes no props. It renders Previous, Next and Close, for when you want the default buttons beside your own.
Fluidbox.Previous, Fluidbox.Next, Fluidbox.Close#
Each takes button attributes and forwards ref. With no children it shows the default icon and an aria-label from labels. disabled defaults to the viewer's state: Previous and Next at the ends of a gallery that doesn't loop, Close while the viewer is closed. Your onClick runs first, and event.preventDefault() in it stops the command.
Fluidbox.Filmstrip#
| Prop | Type | Purpose |
|---|---|---|
children |
(ctx: FluidboxItemContext<TItem>) => ReactNode |
Renders one thumbnail, { item, index }, into its button. Leave it out for the default thumbnails. |
The strip marks the selected button data-fluidbox-selected, so your thumbnails don't track the selection. Annotate the item type with <Fluidbox.Filmstrip<Photo>> when you need your own fields.
Fluidbox.Sheet and Fluidbox.SheetStop#
| Prop | Type | Default | Purpose |
|---|---|---|---|
snapPoints |
readonly FluidboxSheetSnapPoint[] |
["60%", "content"] |
The stops above the resting one, lowest first: pixels, a percentage, or "content". |
dismiss |
"flight" | "scroll" | "none" |
"flight" |
What a swipe down on the photo does with the sheet down. |
children |
(ctx: FluidboxItemContext<TItem>) => ReactNode |
The sheet's content for the item on show, { item, index }. |
|
| ... | className, style, id, aria-*, data-*, ... |
Set on the viewer's <section data-fluidbox-sheet>, which is the sheet. No event handlers. |
The viewer makes the sheet, a <section data-fluidbox-sheet> named by labels.sheet. The render child draws into it for the item on show, and draws again when the item changes. Annotate the item type with <Fluidbox.Sheet<Photo>> when you need your own fields.
<Fluidbox.Sheet snapPoints={["40%", "90%"]} className="details">
{({ item }) => <h3>{itemLabel(item)}</h3>}
</Fluidbox.Sheet>Fluidbox.SheetStop takes stop and button attributes, and moves the sheet to that stop, or back to 0 when the sheet is already there. It is a button with data-fluidbox-sheet-stop, which works anywhere in the viewer.
Hooks#
Call them in a component inside Fluidbox.Root.
| Hook | Returns | Purpose |
|---|---|---|
useFluidbox() |
FluidboxApi<TItem> |
The state (status, id, index, item, count, hasPrevious, hasNext) with open, close, next, previous, setItems, preload and load. Re-renders on every state change. |
useFluidbox(selector) |
the selected value | The part the selector picks. Re-renders only when that value changes (Object.is). |
useFluidboxSheet() |
FluidboxSheetApi |
{ stop, set }: the stop the sheet rests at, following sheetchange, and set(stop, { animate? }) to move it. stop is 0 without a sheet. |
Commands resolve true once they happen, and false when they were prevented or superseded. A command you call before the viewer exists, such as a deep link on mount, waits for the viewer and then runs. open takes the item, its id or its index. Without one, it opens the current item, or the first if there is none. preload(target) starts loading an item a press is likely to open, and returns a function that lets go. load() loads the viewer's code now.
import { useFluidbox } from "@ogdakke/fluidbox/react";
function Counter() {
const { index, count, open } = useFluidbox();
return (
<button type="button" onClick={() => open(0)}>
{index === null ? `Open ${count} photos` : `${index + 1} / ${count}`}
</button>
);
}See the API reference for the state, the events and the items.
Types#
The entry exports FluidboxRootProps, FluidboxTriggerProps, FluidboxTriggerPreview, FluidboxThumbnailOverlayProps, FluidboxFrameLayerProps, FluidboxViewerProps, FluidboxActionProps, FluidboxFilmstripProps, FluidboxSheetProps, FluidboxItemContext, FluidboxSheetStopProps, FluidboxReactRenderer, FluidboxReactRenderContext, FluidboxCommands, FluidboxApi, FluidboxSheetApi, UseFluidbox and FluidboxReactParts (the type of Fluidbox). It also re-exports the item, state and event types from @ogdakke/fluidbox, FluidboxItemChangeEvent and FluidboxSheetEvent among them.
Patterns#
| Pattern | In React |
|---|---|
| Gallery | Root items, a Trigger per item, Previous, Next and Close in Viewer. |
| One trigger for an album | Trigger item={album[0]} returns={album} with a render function, ThumbnailOverlay travel. |
| Rich origins | data-fluidbox-origin on an element inside the Trigger; asChild for your own element. |
| Custom content | Root renderers={{ note: () => <Note /> }}. |
| Programmatic control | useFluidbox(), onBeforeItemChange and the other event props. |
| Zoom | Root zoom={{ step, max }} or zoom={false}. |
| Filmstrip | <Fluidbox.Filmstrip />, or with a render child for your own thumbnails. |
| Sheet | Fluidbox.Sheet snapPoints with a render child, useFluidboxSheet(), Fluidbox.SheetStop. |
| Headless controls | Your own buttons in Viewer, using useFluidbox. |
| Right to left | Root labels, inside a dir="rtl" element. |
| Photo in the address | open, index, onOpenChange and onIndexChange from state that follows ?photo=. |
React notes#
Props change the open viewer in place. Changes to labels and zoom go to the viewer through update without closing it. So does adding or removing Fluidbox.Viewer, Fluidbox.Filmstrip or its render child, or Fluidbox.Sheet, and so does changing the sheet's snapPoints and dismiss. The binding compares these by value, so inline objects and arrays are fine. It reads renderers, origins, labels.position and the event props when it uses them.
Keep items stable. A new items value replaces the items in the next layout effect. Keep the array in state, a module constant or useMemo, and replace it when the gallery changes. An open viewer stays on the same item id when the new items still have it.
Context reaches custom content. Renderers, the Viewer, travelling overlays, and the filmstrip and sheet content render through React portals inside Root. They read context provided around Root, as the Custom content example does with a theme.
asChild. The trigger merges its props and handlers onto your element and composes your ref and your onClick, onPointerDown, onPointerUp and onPointerCancel. The child must be one element that renders a DOM node and accepts a ref. Use an element that can take focus, such as a <button> or <a href>.
Stopping an open. Your onClick on the trigger runs before the open. Call event.preventDefault() to stop it, or cancel onBeforeToggle.
Server rendering and StrictMode. The parts render their markup on the server, and Root creates the viewer in a layout effect in the browser. Triggers bind to a stand-in during render, so they work before the viewer exists. Root works under <StrictMode>. When StrictMode runs the effects again, Root tears the viewer down and makes it again. For Next.js, see Next.js.
Previews update before the close flight. When the viewer closes on a different item of a multi-item trigger, the trigger re-renders synchronously (flushSync) so the flight lands on the right picture. The trigger finds its image when it registers, so keep the <img> mounted and change its src instead of swapping in a new element.
Bundle. The page loads the triggers, the items and the viewer's state. The viewer itself, with its gestures, transitions and slides, loads as loading says, by default when a pointer rests on a trigger. See Loading the viewer. The filmstrip and sheet runtimes load when a Fluidbox.Filmstrip or Fluidbox.Sheet first renders, so they work without an import of their own and cost nothing until then.