Frameworks

Angular

@ogdakke/fluidbox/angular is a set of standalone directives. fluidboxRoot on any element owns one viewer, fluidboxTrigger on an element inside opens it, and <ng-template fluidboxViewer> puts your own content in the viewer. It needs @angular/core 17 or later, and works with or without zone.js.

Install#

shell
pnpm add @ogdakke/fluidbox
ts
import { FluidboxModule, injectFluidbox, injectFluidboxSheet } from "@ogdakke/fluidbox/angular";
import "@ogdakke/fluidbox/styles.css";

Add @ogdakke/fluidbox/styles.css once, either as an import or in the styles array of angular.json. Put FluidboxModule in a component's imports to get every part, or import the directives you use one by one. The filmstrip and sheet parts load their add-ons the first time a root uses them, so there is nothing else to import for them except their stylesheets, @ogdakke/fluidbox/filmstrip.css and @ogdakke/fluidbox/sheet.css.

ts
import { Component } from "@angular/core";
import { FluidboxModule, type FluidboxImageItem } from "@ogdakke/fluidbox/angular";

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 },
  },
];

@Component({
  selector: "app-photos",
  standalone: true,
  imports: [FluidboxModule],
  template: `
    <div fluidboxRoot [items]="photos">
      @for (photo of photos; track photo.src) {
        <button [fluidboxTrigger]="photo">
          <img [src]="photo.thumbnail?.src" [alt]="photo.alt" />
        </button>
      }
    </div>
  `,
})
export class Photos {
  readonly photos = photos;
}

Without a fluidboxViewer template, the viewer has the default Previous, Next and Close buttons. With one, you place them yourself, with the control parts or with fluidboxControls for all three. The same gallery, live, in every framework:

Loading the live example…

basic.tsx
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>
  );
}

Parts#

Selector Class Role
[fluidboxRoot] FluidboxRootDirective Owns one viewer for the items. Every other part must be inside its element.
[fluidboxTrigger] FluidboxTriggerDirective Makes its element open an item, and registers it as the item's origin.
[fluidboxThumbnailOverlay] FluidboxThumbnailOverlayComponent A badge or caption on the thumbnail that fades or flies with the photo.
[fluidboxFrameLayer] FluidboxFrameLayerDirective Inside a travelling overlay, an element that moves into the flying frame for the flight.
ng-template[fluidboxViewer] FluidboxViewerDirective Renders the template into the viewer's body-level layer, in place of the default controls.
[fluidboxControls] FluidboxControlsComponent The default Previous, Next and Close buttons, for a fluidboxViewer template.
button[fluidboxPrevious], button[fluidboxNext], button[fluidboxClose] FluidboxPreviousDirective, FluidboxNextDirective, FluidboxCloseDirective Run the command, with the default icon and label, and disable themselves when they can't.
[fluidboxFilmstrip] FluidboxFilmstripDirective Turns on the filmstrip; on an ng-template, the template fills each thumbnail.
ng-template[fluidboxSheet] FluidboxSheetDirective Turns on the sheet and fills it for the active item.
button[fluidboxSheetStop] FluidboxSheetStopDirective Moves the sheet to a stop when clicked.

FluidboxModule imports and exports all of them, for standalone components and NgModules alike.

fluidboxRoot#

Exported as fluidbox: <div fluidboxRoot #fb="fluidbox">.

Input 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, for [(open)]. 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, for [(index)]. An index out of range is ignored.
labels Partial<FluidboxLabels> English Your strings for the viewer and the control parts.
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, TemplateRef | Type<unknown>> {} Draw custom items, by the name in their render: a template or a component. See below.
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.
Output Payload When
(beforeToggle) FluidboxToggleEvent Before the viewer opens or closes. $event.preventDefault() keeps it as it is.
(toggle) FluidboxToggleEvent The opening or closing motion starts.
(beforeItemChange) FluidboxItemChangeEvent<TItem> Before the open viewer moves to another item. $event.preventDefault() keeps it still.
(itemChange) FluidboxItemChangeEvent<TItem> The viewer moves to an item, including the one it opens on.
(settle) FluidboxItemChangeEvent<TItem> The viewer comes to rest on an item.
(sheetChange) FluidboxSheetEvent The sheet comes to rest at another stop: $event.stop and $event.reason.
(openChange) boolean The viewer opens or closes, from any input. Half of [(open)].
(indexChange) number The viewer moves to another item, opening included. Half of [(index)].

The events are the plain API's. See Events for their fields and reasons.

Member Type Purpose
state Signal<FluidboxState<TItem>> status, id, index, item, count, hasPrevious and hasNext.
open(target?, options?) Promise<boolean> Opens an item, by the item, its index or its id, or moves to it while open. Resolves false when prevented. A command before the viewer exists waits for it.
close(), next(), previous() Promise<boolean> The other commands. Each takes { signal } too.
setItems(items) Promise<boolean> Replaces the items, as a new [items] does.
preload(target) () => void Starts loading an item a press is likely to open. Call the result to let go.
load() Promise<boolean> Loads the viewer's code now. See Loading the viewer.
fluidbox Fluidbox<TItem> The plain fluidbox, with sheet, update and addEventListener. Its destroy() does nothing: the root owns the viewer.
html
<button type="button" (click)="fb.open(0)">Open</button>
<output>{{ fb.state().status }} {{ fb.state().index ?? "-" }}</output>
<div fluidboxRoot #fb="fluidbox" [items]="photos" (itemChange)="onItemChange($event)">…</div>

fluidboxTrigger#

Exported as fluidboxTrigger.

Input Type Purpose
fluidboxTrigger FluidboxTarget<TItem> Required. The item to open: the item, its index or its id.
fluidboxReturns readonly FluidboxTarget<TItem>[] More items that close back onto this trigger, as for an album cover. The opened item always does.
Member Type Purpose
preview Signal<FluidboxTriggerPreview<TItem>> { item, index, status }: the item the trigger shows. That is the item the viewer is on while it is open, then the last one it showed. item is null when the trigger's items have left the list.
target Signal<FluidboxTarget<TItem>> The item it opens.
returns Signal<readonly FluidboxTarget<TItem>[]> The other items that return here.

The directive adds data-fluidbox-trigger, aria-haspopup="dialog" and aria-expanded to its element, gives a <button> without a type type="button", and starts loading the item on pointer down. Put it on an element that can take focus, such as a <button> or <a href>. A trigger that shows the last viewed item opens on it again, so the flight starts from the picture on it.

html
<button [fluidboxTrigger]="album[0]" [fluidboxReturns]="album" #cover="fluidboxTrigger">
  <img [src]="(cover.preview().item ?? album[0]).src" alt="Album" />
</button>

fluidboxThumbnailOverlay and fluidboxFrameLayer#

Input Type Default Purpose
travel boolean false Without it, the overlay stays on the page and gets --fluidbox-closed. With it, its content flies with the photo.

It belongs to the enclosing trigger's item and its fluidboxReturns, and throws outside a fluidboxTrigger. Write the bare attribute for true. Give the content as an ng-template child. The overlay renders it in place, and with travel renders it again into the flying frame. That second copy is a live view with DI and change detection, so a bound count or caption stays current in flight. Plain content without a template works too, and flies as a still copy.

html
<span fluidboxThumbnailOverlay travel class="badge">
  <ng-template>
    <span fluidboxFrameLayer class="scrim"></span>
    +{{ album.length - 1 }}
  </ng-template>
</span>

fluidboxFrameLayer marks an element data-fluidbox-frame-layer, on the overlay or inside its template. With travel, that element itself moves into the flying frame for the flight, scaled with the photo, and goes back after, while a still copy holds its place on the card. In the live copy it renders with display: none, so the scrim never shows twice. See Thumbnail overlays for what an overlay gets as the viewer moves.

fluidboxViewer and fluidboxControls#

<ng-template fluidboxViewer> has no inputs. Its view is created when the viewer is, inside the root, and its nodes move into the viewer's layer. Everything in it hides during the opening flight and fades as a close or a drag starts, like the default controls. Put <div fluidboxControls></div> in it for the default buttons; its element takes no box, so the buttons sit in the layer as if they were its own.

fluidboxPrevious, fluidboxNext, fluidboxClose#

On a <button>. A button that is empty after content init (bound text counts as content) gets the default icon and an aria-label from labels, and every button gets type="button" unless it has a type. The viewer's state disables the button: Previous and Next at the ends of the gallery, Close while the viewer is closed.

Input Type Purpose
disabled boolean Overrides the disabled state the viewer gives.

A (click) on the same button runs first; $event.preventDefault() stops the command.

Renderers#

[renderers] maps the render name of custom items to a template or a component. A template gets the context let-item let-index="index" (FluidboxItemContext). A component gets item and index inputs, when it declares them.

html
<div fluidboxRoot [items]="media" [renderers]="{ note: noteTemplate }">…</div>
<ng-template #noteTemplate let-item>
  <article>{{ item.title }}</article>
</ng-template>

fluidboxFilmstrip#

No inputs. <ng-container fluidboxFilmstrip /> turns on the strip with its default thumbnails. On an ng-template, the template fills each thumbnail button, with the context let-item let-index="index". The strip marks the selected button data-fluidbox-selected, so your thumbnail tracks nothing itself.

fluidboxSheet and fluidboxSheetStop#

Input Type Default Purpose
snapPoints readonly FluidboxSheetSnapPoint[] ["60%", "content"] The stops above the resting one, lowest first.
dismiss "flight" | "scroll" | "none" "flight" What a swipe down on the photo does with the sheet down.

The template renders into the sheet, the viewer's <section data-fluidbox-sheet> named by labels.sheet, for the active item, with the context let-item let-index="index" (FluidboxItemContext). It renders again when the item changes. (sheetChange) on the root, and the stop signal of injectFluidboxSheet(), follow its stop. <button fluidboxSheetStop="1">, in the viewer or in the sheet, moves the sheet to stop 1, and back to 0 when it is there. An ng-template has no attributes to pass on, so style the sheet through [data-fluidbox-sheet], or put your class on an element in the template. Filmstrip and sheet has the details of both add-ons.

Inject functions#

Call them in an injection context (a field initializer or constructor) of a component inside the root's element, including the viewer, renderer, filmstrip and sheet templates.

Function Returns Purpose
injectFluidbox() Signal<FluidboxApi<TItem>> The state of the root above, with open, close, next, previous, setItems, preload and load on it.
injectFluidbox(select) Signal<T> What select picks from it, such as ({ index }) => index.
injectFluidboxSheet() FluidboxSheetApi { stop, set }: the stop the sheet rests at, as a signal that follows sheetchange, and set(stop) to move it.

Both throw outside a root. The component whose template holds fluidboxRoot can't inject it; use the #fb="fluidbox" reference or @ViewChild(FluidboxRootDirective) there.

ts
import { Component } from "@angular/core";
import { injectFluidbox } from "@ogdakke/fluidbox/angular";

@Component({
  selector: "app-counter",
  standalone: true,
  template: `
    <button type="button" [disabled]="!fluidbox().hasNext" (click)="fluidbox().next()">
      {{ (fluidbox().index ?? 0) + 1 }} / {{ fluidbox().count }}
    </button>
  `,
})
export class Counter {
  readonly fluidbox = injectFluidbox();
}

Types#

The entry exports FluidboxApi, FluidboxSheetApi, FluidboxItemContext (the { item, index } context of the sheet, filmstrip and renderer templates), FluidboxContent and FluidboxTriggerPreview, and re-exports the item, state and event types and classes of @ogdakke/fluidbox. TItem comes from [items] on the root and from the inputs of each part; pass it to injectFluidbox<Photo>() for your own item type.

Patterns#

Pattern In Angular
Gallery [items], [fluidboxTrigger] per item, and fluidboxPrevious, fluidboxNext and fluidboxClose in fluidboxViewer.
One trigger for an album [fluidboxTrigger]="album[0]" [fluidboxReturns]="album" #t="fluidboxTrigger", t.preview(), and fluidboxThumbnailOverlay travel with an ng-template.
Rich origins data-fluidbox-origin on an element inside the trigger. The directive goes on your own element, so there is no asChild.
Custom content [renderers]="{ note: Note }" with your component.
Programmatic control #fb="fluidbox", fb.open(2), fb.state(), and (beforeItemChange) with $event.preventDefault().
Zoom [zoom]="{ step: 3, max: 6 }" or [zoom]="false".
Filmstrip <ng-container fluidboxFilmstrip />, or <ng-template fluidboxFilmstrip let-index="index">.
Sheet <ng-template fluidboxSheet [snapPoints]>, injectFluidboxSheet() and fluidboxSheetStop.
Headless controls Your own component in fluidboxViewer, using injectFluidbox().
Right to left [labels] on the root, inside a dir="rtl" element.
Photo in the address [open], [index], (openChange) and (indexChange) with a signal that follows ?photo=.

Angular notes#

When the viewer exists. The root makes the viewer once, right after the first change detection pass, once every part inside has rendered. With a filmstrip or sheet, it also waits for that add-on to load. It lives as long as the root. Commands made before then wait for it and run once it exists.

Options apply in place. The root passes new labels or zoom to the viewer through fluidbox.update(), so an open viewer stays open. The same goes for a fluidboxViewer, fluidboxFilmstrip or fluidboxSheet that comes, goes or changes its inputs. Each part hands over a renderer that stays the same for its life, so only what changed is drawn again. The root reads origins, renderers and the outputs when it uses them. New items go through setItems, and an open viewer stays on the same item id if the new list still has it.

Change detection. The viewer listens to pointer and key events outside the Angular zone, so gestures and animation never trigger change detection. Outputs, state updates and template mounts come back into the zone. state, preview and the inject functions are signals, so OnPush components and zoneless apps update without extra work.

DI reaches custom content. Viewer, filmstrip and sheet templates render as embedded views where they are declared, and renderers as views of the root, so their components inject from the surrounding tree, as the Custom content example does with a theme token.

Stopping an open. The trigger adds its listeners after the template's own, so a (click) on the same element runs first and $event.preventDefault() stops the open. Prevented in (pointerdown), it only skips loading the item early.

Server rendering. The parts skip all DOM work on the server (PLATFORM_ID is not "browser") and build the viewer in the browser.

Previews update before the close flight. When the viewer closes on a different item of a multi-item trigger, a trigger whose preview() is read runs change detection synchronously, so the flight lands on the right picture. The trigger finds its image when it registers, so keep the <img> in place and bind its src; don't swap in a new element.