Guides
Filmstrip and sheet
Both are optional add-ons with their own entries, so their code loads only when you use them. Turn one on with the filmstrip and sheet options of createFluidbox, the Filmstrip and Sheet parts in each framework, or a <fluidbox-filmstrip> or <fluidbox-sheet> child of <fluidbox-gallery>.
import { createFluidbox } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/filmstrip";
import "@ogdakke/fluidbox/sheet";
import "@ogdakke/fluidbox/styles.css";
import "@ogdakke/fluidbox/filmstrip.css";
import "@ogdakke/fluidbox/sheet.css";
const fluidbox = createFluidbox({
items: photos,
filmstrip: true,
sheet: {
render({ item, target }) {
target.textContent = item.id ?? "";
},
},
});The fluidbox loads an add-on's entry the first time you turn it on, and an open waits for it. Import the entry yourself, as above, to have it in the page's bundle from the start instead. The entries are safe to import on the server.
Filmstrip#
The filmstrip is a strip of thumbnails for a gallery of more than one item. A tap moves to that item, a drag or a wheel scrubs through them, and the strip follows swipes, arrow keys and open(target). It renders only the thumbnails in view, so it works for lazy lists of any size. Its styles are in @ogdakke/fluidbox/filmstrip.css.
The strip is viewer content like your own. It mounts beside the viewer's layer, in [data-fluidbox-addons], so a redraw of the layer, or a viewer renderer that clears its target, leaves it be. It reveals and dismisses with the layer, as data-fluidbox-chrome, and floats over the slides. filmstrip.css runs it along the bottom and keeps the photo above it by --fluidbox-filmstrip-space. Your CSS on [data-fluidbox-filmstrip-dock] puts it anywhere else, over the photo or beside it. Set --fluidbox-filmstrip-space: 0px when the photo should run under it.
--fluidbox-filmstrip-axis: y on the dialog runs it as a column, down the left unless you move it. The add-on reads the axis from CSS, so a media query can turn a phone's row into a wide screen's column. Its listbox carries aria-orientation, and a wheel turned either way scrolls it along.
@media (min-width: 900px) {
[data-fluidbox-dialog] {
--fluidbox-filmstrip-axis: y;
}
}filmstrip: true draws each item's thumbnail. An object, FluidboxFilmstripOptions, changes that:
| Option | Type | What it does |
|---|---|---|
render |
FluidboxRenderer<TItem> |
Fill each thumbnail button yourself instead of the default image. |
render gets { item, index, target, signal }. target is the thumbnail <button>. When the button scrolls out of the rendered window, signal aborts and the function you return runs. By default each button holds the item's thumbnail, else the full image or the video's poster.
import { createFluidbox } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/filmstrip";
const fluidbox = createFluidbox({
items: photos,
filmstrip: {
render({ index, target }) {
target.textContent = String(index + 1);
},
},
});Each button is a role="option" in a listbox named by the filmstripList label. It has data-fluidbox-filmstrip-item, data-index, aria-selected and data-fluidbox-selected on the current item, and the item's alt text or title as its name. With a thumbnail focused, the arrow keys along the strip step through the items, Home and End go to the ends, and focus follows the selection. The strip carries data-fluidbox-owns-input, so its touches, wheel and keys scrub the strip rather than move or close the viewer. A move from the strip has the reason of its input, "pointer", "wheel" or "keyboard", and a scrub through it is a scrub move.
render runs for each button as it enters the strip's rendered window, and its signal aborts as the button leaves. The framework Filmstrip parts render through it. Put the part in the root, with a child that draws one thumbnail. See the framework pages for each one's form.
Loading the live example…
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/react";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";
function Thumbnails() {
return (
<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>
);
}
export function Filmstrip() {
return (
<>
<Fluidbox.Root items={photos}>
<Thumbnails />
{/* Being there turns the filmstrip on. */}
<Fluidbox.Filmstrip />
</Fluidbox.Root>
<Fluidbox.Root items={photos}>
<Thumbnails />
{/* Your own thumbnails. The selected button has data-fluidbox-selected. */}
<Fluidbox.Filmstrip>
{({ index }) => (
<span className="pattern-filmstrip-thumb">
{index + 1}
</span>
)}
</Fluidbox.Filmstrip>
</Fluidbox.Root>
</>
);
}<script setup lang="ts">
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/vue";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";
</script>
<template>
<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.Filmstrip />
</Fluidbox.Root>
<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>
<!-- Your own thumbnails. The selected button has data-fluidbox-selected. -->
<Fluidbox.Filmstrip v-slot="{ index }">
<span class="pattern-filmstrip-thumb">{{ index + 1 }}</span>
</Fluidbox.Filmstrip>
</Fluidbox.Root>
</template>import { For } from "solid-js";
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/solid";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";
function Thumbnails() {
return (
<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>
);
}
export function Filmstrip() {
return (
<>
<Fluidbox.Root items={photos}>
<Thumbnails />
<Fluidbox.Filmstrip />
</Fluidbox.Root>
<Fluidbox.Root items={photos}>
<Thumbnails />
{/* Your own thumbnails. The selected button has data-fluidbox-selected. */}
<Fluidbox.Filmstrip>
{(thumb) => (
<span class="pattern-filmstrip-thumb">
{thumb.index + 1}
</span>
)}
</Fluidbox.Filmstrip>
</Fluidbox.Root>
</>
);
}import { Component } from "@angular/core";
import { itemLabel } from "@ogdakke/fluidbox";
import { FluidboxModule } from "@ogdakke/fluidbox/angular";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";
@Component({
selector: "pattern-filmstrip",
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>
}
<ng-container fluidboxFilmstrip />
</div>
<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>
}
<!-- Your own thumbnails. The selected button has data-fluidbox-selected. -->
<ng-template fluidboxFilmstrip let-index="index">
<span class="pattern-filmstrip-thumb">{{ index + 1 }}</span>
</ng-template>
</div>
`,
})
export class Filmstrip {
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-filmstrip></fluidbox-filmstrip>
</fluidbox-gallery>
<fluidbox-gallery class="pattern-grid numbered-strip">
<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-filmstrip></fluidbox-filmstrip>
</fluidbox-gallery>import "@ogdakke/fluidbox/filmstrip.css";
export function filmstrip(root: HTMLElement) {
// Your own thumbnails. The selected button has data-fluidbox-selected.
const strip = root.querySelector(".numbered-strip")!.querySelector("fluidbox-filmstrip")!;
strip.render = ({ index, target }) => {
const thumb = document.createElement("span");
thumb.className = "pattern-filmstrip-thumb";
thumb.textContent = String(index + 1);
target.append(thumb);
};
}import { createFluidbox, itemLabel, type FluidboxOptions } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/filmstrip";
import "@ogdakke/fluidbox/filmstrip.css";
import { photos } from "./data";
function gallery(root: HTMLElement, filmstrip: FluidboxOptions["filmstrip"]) {
const fluidbox = createFluidbox({ items: photos, filmstrip });
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();
};
}
export function mountFilmstrip(root: HTMLElement) {
const standard = gallery(root, true);
// Your own thumbnails. The selected button has data-fluidbox-selected.
const custom = gallery(root, {
render({ index, target }) {
const thumb = document.createElement("span");
thumb.className = "pattern-filmstrip-thumb";
thumb.textContent = String(index + 1);
target.append(thumb);
return () => thumb.remove();
},
});
return () => {
standard();
custom();
};
}On custom elements, a <fluidbox-filmstrip> child adds the strip. Set its render property to fill the buttons yourself.
Sheet#
The sheet is a panel that rises from the bottom over the photo, for details, comments or a form. The photo and the sheet share one native scroller with a snap point per stop, so swipes, momentum and rubber-banding are the browser's own. When the sheet reaches the photo, it pushes the photo up with a gap between them. The push is layout, with no animation, so the photo keeps up with the fastest swipe. Import @ogdakke/fluidbox/sheet.css with it.
sheet: true turns it on with the defaults. An object, FluidboxSheetOptions, sets them:
| Option | Type | Default | What it does |
|---|---|---|---|
snapPoints |
FluidboxSheetSnapPoint[] |
["60%", "content"] |
The stops above the resting one, lowest first. |
dismiss |
"flight" | "scroll" | "none" |
"flight" |
What a swipe down on the photo does at rest. |
render |
FluidboxRenderer<TItem> |
Fill the sheet for the item on show. |
Stops#
Stop 0 is the sheet at rest, either out of sight or showing --fluidbox-sheet-peek of itself. Each snap point, a FluidboxSheetSnapPoint, adds a stop above it:
- A number is pixels of sheet showing.
"40%"is a percentage of the viewer's height."content"is the sheet's own height, so it sits lower when there is little content.
A sheet is always tall enough for its highest pixel or percentage stop. No stop goes above --fluidbox-sheet-top from the top of the screen. Stops closer than 24 pixels count as one.
dismiss sets what a swipe down on the photo does while the sheet is at rest. "flight" closes the viewer with the usual flight home. A second flick down while the sheet is still lowering goes straight into it. "scroll" scrolls the photo off the bottom of the screen and closes, and the backdrop fades as it goes. "none" keeps the viewer open, as for a caption that always peeks.
The sheet handle#
With the sheet on, fluidbox.sheet is a FluidboxSheet that you drive and read:
| Member | What it does |
|---|---|
stop |
The stop the sheet rests at: 0, then one per snap point. |
set(stop, options?) |
Move to a stop, clamped to the stops there are. { animate: false } jumps. Ignored while the viewer isn't open or the sheet is off. |
Each time the sheet comes to rest at another stop, the fluidbox fires sheetchange, a FluidboxSheetEvent with stop and reason. The reason is "pointer" for a swipe or a tap, "wheel" for a wheel or trackpad, "keyboard" for Escape or a key on a stop button, "api" for set() and for the return to rest as the viewer opens or closes.
fluidbox.addEventListener("sheetchange", ({ stop, reason }) => {
console.log(`sheet at stop ${stop}, by ${reason}`);
});In the frameworks, useFluidboxSheet() returns { stop, set }, with stop in the framework's reactive form. The root takes the event as onSheetChange, @sheetchange or (sheetChange), and a SheetStop part is a button that moves the sheet to its stop. On custom elements, gallery.sheet is the handle and the gallery fires sheetchange.
The sheet's content#
The sheet is a <section data-fluidbox-sheet data-fluidbox-chrome> that the runtime makes, named by the sheet label. render({ item, index, target, signal }) fills it for the item on show. When the viewer moves to another item, signal aborts, the returned function runs, and the runtime empties the section before the next item's render. The framework Sheet parts render through it, so their children re-render with the app's state, and they put their own attributes on the section.
<fluidbox-sheet> shows the active item's <template slot="sheet">, else its own <template>. Set its render property to draw the sheet yourself. Its snap-points and dismiss attributes are the options: <fluidbox-sheet snap-points="40% 90%" dismiss="scroll">.
Put these inside the sheet element:
| Selector | Part |
|---|---|
[data-fluidbox-sheet-handle] |
Draws a grab bar. Make it the sheet's first child. |
[data-fluidbox-sheet-scroll] |
The part that scrolls once the sheet is at its top stop. Without one, the whole sheet does. |
Below the top stop, a swipe anywhere on the sheet moves the sheet. At the top stop its content scrolls, and a pull down from the top of the content carries on into the sheet. Put padding on an inner element, not on [data-fluidbox-sheet].
Loading the live example…
import { itemLabel, type FluidboxItem } from "@ogdakke/fluidbox";
import { Fluidbox, useFluidboxSheet } from "@ogdakke/fluidbox/react";
import "@ogdakke/fluidbox/sheet.css";
import { photos } from "./data";
function Details({ item }: { item: FluidboxItem }) {
const sheet = useFluidboxSheet();
return (
<>
<h3>{itemLabel(item)}</h3>
<p>
Stop <output>{sheet.stop}</output>
</p>
<button type="button" onClick={() => sheet.set(2)}>
Expand
</button>
<input placeholder="Add a note" aria-label="Note" />
</>
);
}
export function Sheet() {
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>
{/* Stop 0 rests below the photo; stops 1 and 2 raise it to 40% and 90% of the screen. */}
<Fluidbox.Sheet snapPoints={["40%", "90%"]} className="pattern-sheet">
{({ item }) => <Details item={item} />}
</Fluidbox.Sheet>
<Fluidbox.Viewer>
<Fluidbox.SheetStop stop={1} className="pattern-sheet-toggle">
Details
</Fluidbox.SheetStop>
<Fluidbox.Controls />
</Fluidbox.Viewer>
</Fluidbox.Root>
);
}<script setup lang="ts">
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/vue";
import "@ogdakke/fluidbox/sheet.css";
import { ref } from "vue";
import { photos } from "./data";
// The stop the sheet rests at: 0 at rest, then one per snap point.
const stop = ref(0);
</script>
<template>
<Fluidbox.Root :items="photos" @sheetchange="stop = $event.stop">
<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.Viewer>
<Fluidbox.Controls />
<!-- At stop 0 the sheet is out of sight; this raises it. -->
<Fluidbox.SheetStop :stop="1" class="pattern-sheet-toggle">
Details
</Fluidbox.SheetStop>
</Fluidbox.Viewer>
<!-- The sheet's content, for the active item. Its attributes go on the sheet. -->
<Fluidbox.Sheet v-slot="{ item }" :snap-points="['40%', '90%']" class="pattern-sheet">
<h3>{{ itemLabel(item) }}</h3>
<p>
Stop <output>{{ stop }}</output>
</p>
<Fluidbox.SheetStop :stop="2">Expand</Fluidbox.SheetStop>
<input placeholder="Add a note" aria-label="Note" />
</Fluidbox.Sheet>
</Fluidbox.Root>
</template>import { For } from "solid-js";
import { itemLabel, type FluidboxImageItem } from "@ogdakke/fluidbox";
import { Fluidbox, useFluidboxSheet } from "@ogdakke/fluidbox/solid";
import "@ogdakke/fluidbox/sheet.css";
import { photos } from "./data";
function Details(props: { photo: FluidboxImageItem }) {
const sheet = useFluidboxSheet();
return (
<>
<h3>{itemLabel(props.photo)}</h3>
<p>
Stop <output>{sheet.stop()}</output>
</p>
<button type="button" onClick={() => sheet.set(2)}>
Expand
</button>
<input placeholder="Add a note" aria-label="Note" />
</>
);
}
export function Sheet() {
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>
{/* The sheet's content, for the active item. Its attributes go on the sheet itself. */}
<Fluidbox.Sheet<FluidboxImageItem> snapPoints={["40%", "90%"]} class="pattern-sheet">
{({ item }) => <Details photo={item} />}
</Fluidbox.Sheet>
<Fluidbox.Viewer>
{/* Raises the sheet to its first stop. */}
<Fluidbox.SheetStop stop={1} class="pattern-sheet-toggle">
Details
</Fluidbox.SheetStop>
<Fluidbox.Controls />
</Fluidbox.Viewer>
</Fluidbox.Root>
);
}import { Component, Input } from "@angular/core";
import { itemLabel } from "@ogdakke/fluidbox";
import {
FluidboxModule,
injectFluidboxSheet,
type FluidboxImageItem,
} from "@ogdakke/fluidbox/angular";
import "@ogdakke/fluidbox/sheet.css";
import { photos } from "./data";
@Component({
selector: "div[appDetails]",
standalone: true,
host: { class: "pattern-sheet" },
template: `
<h3>{{ label(photo) }}</h3>
<p>
Stop <output>{{ sheet.stop() }}</output>
</p>
<button type="button" (click)="sheet.set(2)">Expand</button>
<input placeholder="Add a note" aria-label="Note" />
`,
})
export class Details {
@Input({ required: true }) photo!: FluidboxImageItem;
readonly sheet = injectFluidboxSheet();
readonly label = itemLabel;
}
@Component({
selector: "pattern-sheet",
standalone: true,
imports: [FluidboxModule, Details],
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>
}
<!-- Stop 0 rests below the photo; stops 1 and 2 raise it to 40% and 90% of the screen. -->
<ng-template fluidboxSheet [snapPoints]="['40%', '90%']" let-photo>
<div appDetails [photo]="photo"></div>
</ng-template>
<ng-template fluidboxViewer>
<div fluidboxControls></div>
<!-- A fluidboxSheetStop button moves the sheet to that stop. -->
<button fluidboxSheetStop="1" class="pattern-sheet-toggle">Details</button>
</ng-template>
</div>
`,
})
export class Sheet {
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>
<template slot="viewer">
<fluidbox-controls></fluidbox-controls>
<button
type="button"
class="pattern-sheet-toggle"
data-fluidbox-sheet-stop="1"
>
Details
</button>
</template>
<fluidbox-sheet snap-points="40% 90%">
<template>
<div class="pattern-sheet">
<h3></h3>
<p>Stop <output name="stop">0</output></p>
<button type="button" data-fluidbox-sheet-stop="2">Expand</button>
<input type="text" aria-label="Note" placeholder="Add a note" />
</div>
</template>
</fluidbox-sheet>
</fluidbox-gallery>import { itemLabel } from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/sheet.css";
export function sheet(root: HTMLElement) {
const gallery = root.querySelector("fluidbox-gallery")!;
const sheet = gallery.querySelector("fluidbox-sheet")!;
const content = sheet.querySelector("template")!;
// Fill the sheet for the active item; without this it shows the template as is.
sheet.render = ({ item, target, signal }) => {
target.append(content.content.cloneNode(true));
target.querySelector("h3")!.textContent = itemLabel(item);
const stop = target.querySelector<HTMLOutputElement>('output[name="stop"]')!;
stop.value = String(gallery.sheet!.stop);
gallery.addEventListener("sheetchange", (event) => (stop.value = String(event.stop)), {
signal,
});
};
}import {
createFluidbox,
itemLabel,
type Fluidbox,
type FluidboxImageItem,
} from "@ogdakke/fluidbox";
import "@ogdakke/fluidbox/sheet";
import "@ogdakke/fluidbox/sheet.css";
import { photos } from "./data";
export function mountSheet(root: HTMLElement) {
const fluidbox: Fluidbox<FluidboxImageItem> = createFluidbox({
items: photos,
sheet: {
snapPoints: ["40%", "90%"],
// Fills the sheet for the active item.
render({ item, target, signal }) {
const sheet = fluidbox.sheet!;
target.innerHTML = `
<div class="pattern-sheet">
<h3></h3>
<p>Stop <output name="stop"></output></p>
<button type="button" data-action="expand">Expand</button>
<input placeholder="Add a note" aria-label="Note" />
</div>`;
target.querySelector("h3")!.textContent = itemLabel(item);
target
.querySelector('[data-action="expand"]')!
.addEventListener("click", () => sheet.set(2));
const stop = target.querySelector<HTMLOutputElement>('output[name="stop"]')!;
stop.value = String(sheet.stop);
fluidbox.addEventListener("sheetchange", (event) => (stop.value = String(event.stop)), {
signal,
});
},
},
// A button marked data-fluidbox-sheet-stop moves the sheet to that stop.
viewer({ target }) {
target.insertAdjacentHTML(
"beforeend",
`<button type="button" class="pattern-sheet-toggle" data-fluidbox-sheet-stop="1">Details</button>`,
);
},
});
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();
};
}Stop buttons#
Any element in the viewer with data-fluidbox-sheet-stop="N" moves the sheet to stop N when clicked, such as a "Details" button in the viewer's layer or a reply count. Clicked again at that stop, it lowers the sheet to 0. This works in every entry point, as plain markup. The runtime keeps aria-expanded on every stop button, and moves focus into the sheet once it settles at a raised stop. When the sheet goes out of sight, it becomes inert, and focus inside it goes back to the button or the dialog.
<button type="button" data-fluidbox-sheet-stop="1">Details</button>With the sheet raised, Escape and a tap above the sheet lower it instead of closing the viewer. Keys typed in the sheet's fields stay there, so the arrow keys don't change the slide. A tap in a field focuses it without the page panning, and the sheet makes room for the software keyboard. Zoom works only with the sheet at rest.
sheet.css#
@ogdakke/fluidbox/sheet.css holds the sheet's layout and its variables. Set them on [data-fluidbox-dialog]. The defaults have no weight, so any selector wins. The runtime reads the ones that say so as they change, so a media query can set them too.
| Property | Default | What it does |
|---|---|---|
--fluidbox-sheet-enabled |
1 |
0 turns the sheet off, as for wide screens with a sidebar instead. The runtime reads it. |
--fluidbox-sheet-photo |
push |
push slides the photo up ahead of the sheet; fit shrinks it into the room above. The runtime reads it. |
--fluidbox-sheet-peek |
0px |
How much of the sheet shows at rest. Also sets --fluidbox-stage-bottom. The runtime reads it. |
--fluidbox-sheet-gap |
12px |
Space between the photo and the sheet. The runtime reads it. |
--fluidbox-sheet-fit-inset |
0px |
With fit, space the photo keeps clear above and beside it as it shrinks. The runtime reads it. |
--fluidbox-sheet-fit-limit |
55% |
With fit, how much the sheet covers before the photo stops shrinking and rises: a length, or a percentage of the stage. The runtime reads it. |
--fluidbox-sheet-top |
calc(env(safe-area-inset-top, 0px) + 56px) |
Space above the sheet at its tallest. |
--fluidbox-sheet-radius |
18px |
The sheet's top corners. |
--fluidbox-sheet-max-width |
640px |
The sheet's widest. |
--fluidbox-sheet-background |
Canvas |
The sheet's background. |
--fluidbox-sheet-color |
CanvasText |
The sheet's text colour. |
--fluidbox-sheet-fade |
1 |
How much the photo fades between the first stop and the top one. 0 keeps it. |
As the sheet moves, these variables on the overlay follow its scroll. Use them for your own effects:
| Property | On | Value |
|---|---|---|
--fluidbox-sheet-visible |
Overlay | Pixels of the sheet showing. |
--fluidbox-sheet-progress |
Overlay | 0 at rest to 1 at the first stop. |
--fluidbox-sheet-expand |
Overlay | 0 at the first stop to 1 at the top stop. |
--fluidbox-sheet-dismiss |
Overlay | 1 with the photo scrolled away to 0 at rest, with dismiss: "scroll". |
The dialog gets data-sheet with the stop number, data-sheet-raised above stop 0, data-sheet-expanded at the top stop, data-sheet-peeking while the peek is above zero and data-sheet-off while the sheet is turned off. The overlay gets data-sheet-dismiss with the dismiss option.
[data-fluidbox-dialog] .caption {
opacity: clamp(0, 1 - var(--fluidbox-sheet-progress) * 2.5, 1);
}
@media (min-width: 900px) {
[data-fluidbox-dialog]:has(.replies) {
--fluidbox-sheet-enabled: 0;
--fluidbox-stage-right: 360px;
}
}In a browser without scroll-driven animations, the sheet still scrolls, snaps and pushes the photo. With fit, the photo keeps its size behind it.