Patterns
Headless controls
The viewer's layer is yours to fill. Leave out the default controls and render your own caption, counter and buttons. They read the state and call the commands.
Loading the live example…
headless.tsx
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox, useFluidbox } from "@ogdakke/fluidbox/react";
import { photos } from "./data";
function Caption() {
const { item, index, count, hasPrevious, hasNext, previous, next, close } = useFluidbox();
return (
<div className="pattern-caption">
<button type="button" disabled={!hasPrevious} onClick={() => previous()}>
Previous
</button>
<span>
<strong>{item && itemLabel(item)}</strong>{" "}
<small>{index === null ? "" : `${index + 1} / ${count}`}</small>
</span>
<button type="button" disabled={!hasNext} onClick={() => next()}>
Next
</button>
<button type="button" onClick={() => close()}>
Close
</button>
</div>
);
}
export function Headless() {
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>
{/* Your own controls and caption replace the default ones. */}
<Fluidbox.Viewer>
<Caption />
</Fluidbox.Viewer>
</Fluidbox.Root>
);
}Headless.vue
<script setup lang="ts">
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/vue";
import { photos } from "./data";
</script>
<template>
<Fluidbox.Root v-slot="fluidbox" :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>
<!-- Your own controls and caption, in place of the default ones. -->
<Fluidbox.Viewer>
<div class="pattern-caption">
<button
type="button"
:disabled="!fluidbox.hasPrevious"
@click="fluidbox.previous()"
>
Previous
</button>
<span v-if="fluidbox.item && fluidbox.index !== null">
<strong>{{ itemLabel(fluidbox.item) }}</strong>
<small>{{ fluidbox.index + 1 }} / {{ fluidbox.count }}</small>
</span>
<button
type="button"
:disabled="!fluidbox.hasNext"
@click="fluidbox.next()"
>
Next
</button>
<button type="button" @click="fluidbox.close()">Close</button>
</div>
</Fluidbox.Viewer>
</Fluidbox.Root>
</template>headless.tsx
import { For } from "solid-js";
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox, useFluidbox } from "@ogdakke/fluidbox/solid";
import { photos } from "./data";
function Caption() {
const fluidbox = useFluidbox();
return (
<div class="pattern-caption">
<button
type="button"
disabled={!fluidbox.hasPrevious}
onClick={() => fluidbox.previous()}
>
Previous
</button>
<span>
<strong>{fluidbox.item ? itemLabel(fluidbox.item) : ""}</strong>{" "}
<small>
{(fluidbox.index ?? 0) + 1} / {fluidbox.count}
</small>
</span>
<button
type="button"
disabled={!fluidbox.hasNext}
onClick={() => fluidbox.next()}
>
Next
</button>
<button type="button" onClick={() => fluidbox.close()}>
Close
</button>
</div>
);
}
export function Headless() {
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>
{/* Your own controls and caption, in place of the default controls. */}
<Fluidbox.Viewer>
<Caption />
</Fluidbox.Viewer>
</Fluidbox.Root>
);
}headless.ts
import { Component } from "@angular/core";
import { itemLabel } from "@ogdakke/fluidbox";
import { FluidboxModule, injectFluidbox } from "@ogdakke/fluidbox/angular";
import { photos } from "./data";
/** Your own controls and caption. In the viewer, it injects the fluidbox of the root. */
@Component({
selector: "div[appCaption]",
standalone: true,
host: { class: "pattern-caption" },
template: `
<button
type="button"
[disabled]="!fluidbox().hasPrevious"
(click)="fluidbox().previous()"
>
Previous
</button>
<span>
<strong>{{ title() }}</strong>
<small>{{ (fluidbox().index ?? 0) + 1 }} / {{ fluidbox().count }}</small>
</span>
<button
type="button"
[disabled]="!fluidbox().hasNext"
(click)="fluidbox().next()"
>
Next
</button>
<button type="button" (click)="fluidbox().close()">Close</button>
`,
})
export class Caption {
readonly fluidbox = injectFluidbox();
readonly title = injectFluidbox(({ item }) => (item ? itemLabel(item) : ""));
}
@Component({
selector: "pattern-headless",
standalone: true,
imports: [FluidboxModule, Caption],
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>
}
<!-- A viewer template replaces the default controls. -->
<ng-template fluidboxViewer>
<div appCaption></div>
</ng-template>
</div>
`,
})
export class Headless {
readonly photos = photos;
readonly label = itemLabel;
}headless.html
<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>
<template>
<div class="pattern-caption">
<span>
<strong></strong>
<small></small>
</span>
<button type="button" data-action="previous">Previous</button>
<button type="button" data-action="next">Next</button>
<button type="button" data-action="close">Close</button>
</div>
</template>headless.ts
import { itemLabel } from "@ogdakke/fluidbox";
export function headless(root: HTMLElement) {
const gallery = root.querySelector("fluidbox-gallery")!;
const caption = root.querySelector<HTMLTemplateElement>(":scope > template")!;
gallery.viewer = ({ target, fluidbox, signal }) => {
target.append(caption.content.cloneNode(true));
const action = (name: string) =>
target.querySelector<HTMLButtonElement>(`[data-action="${name}"]`)!;
const previous = action("previous");
const next = action("next");
previous.addEventListener("click", () => fluidbox.previous(), { signal });
next.addEventListener("click", () => fluidbox.next(), { signal });
action("close").addEventListener("click", () => fluidbox.close(), { signal });
const title = target.querySelector("strong")!;
const counter = target.querySelector("small")!;
return fluidbox.subscribe(({ item, index, count, hasPrevious, hasNext }) => {
if (!item || index === null) return;
title.textContent = itemLabel(item);
counter.textContent = `${index + 1} / ${count}`;
previous.disabled = !hasPrevious;
next.disabled = !hasNext;
});
};
}headless.ts
import { createFluidbox, itemLabel } from "@ogdakke/fluidbox";
import { photos } from "./data";
export function mountHeadless(root: HTMLElement) {
const fluidbox = createFluidbox({
items: photos,
// Your own controls and caption, drawn into the viewer while it is open.
viewer({ target, fluidbox, signal }) {
const caption = document.createElement("div");
caption.className = "pattern-caption";
caption.innerHTML = `
<button type="button" data-action="previous">Previous</button>
<span><strong></strong> <small></small></span>
<button type="button" data-action="next">Next</button>
<button type="button" data-action="close">Close</button>`;
const action = (name: string) =>
caption.querySelector<HTMLButtonElement>(`[data-action="${name}"]`)!;
const prev = action("previous");
const next = action("next");
const title = caption.querySelector("strong")!;
const counter = caption.querySelector("small")!;
prev.addEventListener("click", () => void fluidbox.previous(), { signal });
next.addEventListener("click", () => void fluidbox.next(), { signal });
action("close").addEventListener("click", () => void fluidbox.close(), { signal });
target.append(caption);
return fluidbox.subscribe(({ item, index, count, hasPrevious, hasNext }) => {
if (!item || index === null) return;
title.textContent = itemLabel(item);
counter.textContent = `${index + 1} / ${count}`;
prev.disabled = !hasPrevious;
next.disabled = !hasNext;
});
},
});
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 () => {
fluidbox.destroy();
grid.remove();
};
}- The framework
Viewerpart renders into the viewer's layer, above the slides, anduseFluidbox()gives the state and commands. In plain JavaScript, thevieweroption draws into it and subscribes to the state. On custom elements, setgallery.viewer, or put a<template slot="viewer">in the gallery. - Buttons marked
data-fluidbox-close,data-fluidbox-previousordata-fluidbox-nextget the default styling, and run their command on the custom elements. Your own buttons callclose(),previous()andnext()directly. - Clicks on your buttons, links and inputs inside the viewer never count as zoom taps.