# frankaa: full reference for AI assistants frankaa (said FRAN-kah) is a photo slider and a photo lightbox for the web. - procession: the slider. A native CSS scroll-snap track: swipes follow the finger 1:1 with momentum. Dots, hover arrows, lazy loading, optional loop and autoplay. - unfurl: the lightbox. The photo springs from its spot on the page to full view, changes photos with an effect, and springs back on close. Two renderers: WebGL (shader effects, built-in or custom) and CSS (transform and opacity only). Framework-free TypeScript, zero runtime dependencies, optional React bindings, ESM only, MIT licence. Docs and playground: https://jabclari.github.io/frankaa/ Repository: https://github.com/JABClari/frankaa ==================================================================== 0. ONE ITEM FORMAT, TWO CALL SHAPES (0.2.0) ==================================================================== Every part takes the same list: type Item = string | ImageItem | VideoItem ImageItem = { src, thumb?, full?, srcset?, sizes?, sources?: [{ media, srcset, sizes?, type? }], alt?, caption?, title?, width?, height?, placeholder? } VideoItem = { type: "video", provider: "youtube" | "vimeo" | "tiktok" | "instagram" | "mp4", src, poster?, thumb?, alt?, caption?, title? } The slider shows src (sources make a ), the lightbox full ?? src, strips (drumline, the filmstrip) thumb ?? src. A video without a poster: an mp4 shows its first frame in the lightbox; elsewhere a neutral 16:9 still. Every part takes (items, options) or ({ items, ...options }); `photos` is the older name for items. procession(el, items, options) unfurl(items, options) drumline(el, items, options) Combining is one option: unfurl(items, { strip: "ruler" }) // lightbox with a drumline filmstrip unfurl(items, { strip: { preset: "numbers", fluid: "off" } }) drumline(el, items, { lightbox: { effect: "ripple", strip: "minimal" } }) // a tap opens the lightbox procession(el, items, { onSelect: (i) => unfurl(items, { start: i, origin: (n) => slider.image(n) }) }) React: const lb = useUnfurl(items, { strip: "ruler" }); lb.open(i) ==================================================================== 1. INSTALL AND IMPORTS ==================================================================== npm install frankaa import { procession, unfurl } from "frankaa"; // both parts import { procession } from "frankaa/slider"; // slider only (about 3.3 kB gzipped) import { unfurl } from "frankaa/lightbox"; // lightbox only (about 16 kB gzipped) import { drumline } from "frankaa/drumline"; // the strip (about 9.4 kB gzipped) import { Procession, useUnfurl } from "frankaa/react"; // React 18+ bindings import "frankaa/style.css"; // required once, anywhere in the app Also exported from "frankaa" and "frankaa/lightbox": supported(): boolean is WebGL available registerEffect(name, effect): void add a named shader effect checkEffect(effect | fragment): string|null compile a shader now; null when it compiles, else the compiler message effectNames(): string[] names registered so far UNFURL_LABELS, PROCESSION_LABELS the default English strings ==================================================================== 2. procession(element, options): the slider ==================================================================== The element keeps its own children; the slider adds a track, dots, arrows and a live region. Default aspect ratio 4:3 (override with CSS: aspect-ratio or height). Options (all optional except photos): photos (Photo | string)[] Photo = { src, srcset?, sizes?, alt?, width?, height? } label string shorthand for labels.region. Default "Photos" labels Partial region, hint, next, previous, pause, play, position ("Photo {n} of {total}") start number default 0 dots boolean default true arrows boolean default true (hover with a mouse, and keyboard focus) snap "mandatory" | "proximity" default "mandatory" loop boolean default false; arrows, keys and autoplay wrap (rewind) autoplay number | false ms between moves (min 1500). Default false. Pauses on hover, focus, hidden tab, off screen, reduced motion; adds a pause button priority boolean first photo loads eagerly with high fetch priority. Default false lazyMargin string IntersectionObserver rootMargin for loading the other photos. Default "100px" preload "near" | "idle" | false near the screen (default); also the next photo when idle; or only on touch reducedMotion "system" | "always" | "never" default "system" announce boolean polite live region on change. Default true href string the track becomes an : Cmd, Ctrl and middle click work natively tabbable boolean default true; false takes it out of the tab order counter boolean a "2 / 5" counter (labels.counter). Default false zoom boolean pinch (springs back) and double tap zoom. Default false zoomCursor boolean zoom-in cursor, when it opens a lightbox. Default false focusRing "outside" | "inset" inset for sliders inside overflow: hidden parents icons { prev?, next?, play?, pause? } 24px stroked path d strings Photo.placeholder: a colour or image address (thumbnail or decoded blurhash) shown until the photo paints. onSelect(index, img, event): the event lets you leave modified clicks to the browser. Hooks: onSlideChange(index, { index, from }), onChange (same), onAnimationStart({ type: "slide", index }), onAnimationEnd(...), onError({ index, src, reason: "load" }), onSelect(index, img) Returns: { index, el, go(i, smooth = true), next(), prev(), play(), pause(), image(i), destroy() } Keyboard: the track is one tab stop. Left and Right step, Home and End jump, Enter calls onSelect. ==================================================================== 3. unfurl(options): the lightbox ==================================================================== Opens immediately and returns controls. It closes itself on Escape, the close button, a tap outside the controls, or a pull down. Options (all optional except photos): photos (UnfurlItem | string)[] images { src, alt?, caption? } and videos { type: "video", provider, src, poster, alt?, caption?, title? }, mixed. Full-size sources. topActions { label, onClick(api), icon? }[] extra top-bar buttons before Close, inside the focus trap; icon = 24px path d controls (host, api) => void | cleanup mount your own controls in the top bar (before Close) start number photo to open on. Default 0 origin (i) => Element | DOMRect | null where photo i sits on the page now. It springs from there and back. Null or off screen: it fades. effect "displacement" | "ripple" | "fade" | "zoom" | "none" | registered name | Effect object. Default "displacement" renderer "auto" | "webgl" | "css" default "auto" (WebGL when available, else CSS) reducedMotion "system" | "always" | "never" default "system"; reduced = short fade, no spring, no effect label string shorthand for labels.dialog labels Partial dialog, close, next, previous, position ("Photo {n} of {total}"), counter ("{n} / {total}") focusTrap boolean default true (also sets aria-modal) returnFocus boolean | HTMLElement default true: focus returns to the element focused before opening announce boolean default true hideOrigin boolean hide the origin element while its photo is out. Default true radius number | [tl,tr,br,bl] | (i) => ... corner radius at the spot. Default 16 (match your thumbnails) openRadius number | [tl,tr,br,bl] corner radius when open. Default 12 zoom boolean | { max } pinch, double tap/click, Ctrl+wheel, + and - zoom; pan while zoomed. Default true (max 4) icons { prev?, next?, close?, play? } 24px stroked path d strings image srcset, sizes (default "100vw") and placeholder are used by both renderers (placeholder: CSS renderer). container HTMLElement mount point and event target. Default document.body strip true | "ruler" | "numbers" | "minimal" | drumline options a filmstrip along the bottom loop boolean wrap from the last photo to the first. Default false (the ends rubber-band) Keyboard: Left and Right move, Home and End jump to the first and last, Escape closes, Tab stays inside. A key move puts the focus ring on the matching arrow button. Gestures: with WebGL a sideways swipe runs the effect under the finger, its progress following the drag; on release the fling's end is projected from its velocity and the effect finishes toward the nearest photo or rewinds, with that velocity. swipe: "strip" (and the CSS renderer, videos and the ends) slides a strip of photos 1:1 with the neighbours peeking in instead. Ends rubber-band unless loop. The axis locks after 10 px; only a clearly vertical drag pulls down to close. Keys and buttons use the effect. Hooks: onOpenStart({ index }), onOpenEnd({ index, renderer }), onCloseStart({ index }), onCloseEnd({ index }), onChange(index, { index, from, how: "key" | "button" | "swipe" | "api" }), onAnimationStart({ type: "open" | "close" | "change", index }), onAnimationEnd(same), onError({ index, src, reason: "cors" | "load" | "webgl" | "shader", fallback: "css" | "none", message? }) Returns: { index, renderer: "webgl" | "css", next(), prev(), show(i), close(), destroy() } api (for actions and controls): { index, item, close(), next(), prev(), show(i) } destroy() while open fires onCloseStart and onCloseEnd with reason "destroy", and ends running animations with cancelled: true. ==================================================================== 3b. drumline(el, items, options): the strip (dondo, the talking drum) ==================================================================== A strip of pictures under a ruler of ticks. Drag or flick it, wheel either way (at the ends the page scrolls on), hover or drag along the ruler, Left/Right/Home/End/Enter. The picture in focus grows; a tap opens it in the lightbox (loaded on first use) and the strip follows the lightbox. Presets (every setting can also be given and wins): "ruler" ticks (hover runs through), story cards, grow 1.55, fluid "bend", colorFocus (default) "numbers" ticks and the number frame "28 [29] 30", narrow bars, grow 1.3, fluid "chain" "minimal" no ticks, "06 / 24" under the strip, story cards, grow 1.18, fluid "lerp" Options: preset, ticks, rulerHover, numbers ("frame" | "fraction" | false), pad (2), focusFeedback, colorFocus, grow, fluid ("off" spring | "lerp" damped follow | "chain" cards trail | "bend" speed bulges the strip, WebGL), look ("stories" | "bars"; size with --fk-dl-w, --fk-dl-h, --fk-dl-gap, --fk-dl-card-radius), renderer ("auto" | "dom"), wheel ("both" | "x" | "y" | false), drag, inertia, snap, expand (true), lightbox (unfurl options), start, label, labels { region, position }, reducedMotion, announce. Bend uses WebGL; it falls back to the DOM when WebGL is missing, the thumbs are cross-origin without CORS headers, or frames average under about 50 fps, firing onError({ reason: "webgl" | "cors" | "slow", fallback: "dom" }). Hooks: onChange(index, { from, how }), onAnimationStart/End({ type: "scroll" }), onSelect(index, img, event), onExpand({ index }), onError. Events: frankaa:change, frankaa:expand, frankaa:error, frankaa:animation-*. Returns { index, renderer, el, go(i, smooth?), next(), prev(), expand(i?), image(i), destroy() }. ==================================================================== 4. EVENTS ==================================================================== Every hook is also a bubbling CustomEvent "frankaa:" + kebab-case name, with the hook's detail object: frankaa:open-start, frankaa:open-end, frankaa:close-start, frankaa:close-end, frankaa:change, frankaa:slide-change, frankaa:animation-start, frankaa:animation-end, frankaa:error. Slider events fire on the slider element. Lightbox events fire on options.container (default document.body). document.addEventListener("frankaa:error", (e) => console.warn(e.detail)); ==================================================================== 5. RECIPES ==================================================================== --- 5.1 Vanilla: a slider card that opens the lightbox ---
--- 5.2 Vanilla: a gallery grid with the lightbox --- const thumbs = [...document.querySelectorAll(".gallery img")]; const photos = thumbs.map((img) => ({ src: img.dataset.full, alt: img.alt })); thumbs.forEach((img, i) => { img.closest("button").addEventListener("click", () => unfurl({ photos, start: i, origin: (n) => thumbs[n], label: "Gallery" })); }); // Wrap each thumbnail in a ))} ); } --- 5.6 The CSS renderer (no WebGL at all) --- unfurl({ photos, origin, renderer: "css", effect: "zoom" }); // Effects in CSS: displacement -> slide, ripple -> zoom, zoom -> zoom, fade -> fade, none -> cut. // Custom effects choose with their css field (default "fade"). --- 5.7 Reduced motion --- unfurl({ photos, reducedMotion: "system" }); // default: follows prefers-reduced-motion unfurl({ photos, reducedMotion: "always" }); // always a short fade, no spring or effect procession(el, { photos, reducedMotion: "never", autoplay: 4000 }); // ignore the setting (use sparingly) --- 5.8 Labels in another language --- unfurl({ photos, labels: { dialog: "Fotos", close: "Cerrar", next: "Siguiente", previous: "Anterior", position: "Foto {n} de {total}", counter: "{n} de {total}" }, }); procession(el, { photos, labels: { region: "Fotos", hint: "usa las flechas para moverte", next: "Siguiente", previous: "Anterior", pause: "Pausar", play: "Reproducir", position: "Foto {n} de {total}" }, }); --- 5.9 Hooks and events --- unfurl({ photos, onOpenStart: ({ index }) => analytics.track("lightbox_open", { index }), onChange: (index, { from, how }) => analytics.track("lightbox_move", { index, from, how }), onAnimationStart: ({ type }) => document.body.classList.add("is-animating"), onAnimationEnd: ({ type }) => document.body.classList.remove("is-animating"), onError: ({ reason, src, message }) => console.warn("frankaa", reason, src, message), onCloseEnd: () => {}, }); document.addEventListener("frankaa:slide-change", (e) => console.log(e.detail.index)); --- 5.10 A custom shader effect --- import { registerEffect, checkEffect, unfurl } from "frankaa"; const fragment = ` precision mediump float; varying vec2 vUv; uniform sampler2D uFrom; uniform sampler2D uTo; uniform float uProgress; uniform vec2 uResolution; uniform vec2 uFromSize; uniform vec2 uToSize; uniform float uTiles; float hash(vec2 q) { return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453); } void main() { vec2 grid = vec2(uTiles * uResolution.x / uResolution.y, uTiles); vec2 cell = floor(vUv * grid); float t = clamp((uProgress - hash(cell) * 0.55) / 0.45, 0.0, 1.0); vec2 uv = mix(vUv, (cell + 0.5) / grid, sin(t * 3.14159)); vec4 a = texture2D(uFrom, mirrorUv(coverUv(uv, uFromSize, uResolution))); vec4 b = texture2D(uTo, mirrorUv(coverUv(uv, uToSize, uResolution))); gl_FragColor = mix(a, b, step(0.5, t)); }`; const problem = checkEffect(fragment); // null, or the compiler message registerEffect("mosaic", { fragment, uniforms: { uTiles: 9 }, duration: 900, easing: "linear", css: "fade" }); unfurl({ photos, effect: "mosaic" }); unfurl({ photos, effect: { fragment, uniforms: { uTiles: 6 } } }); // inline, without registering Shader contract: - GLSL ES 1.0 fragment shader. Start with "precision mediump float;". Write gl_FragColor. - varying vec2 vUv: 0 to 1 across the photo's box, (0, 0) top left. - uniform sampler2D uFrom, uTo: the photo leaving and arriving. - uniform float uProgress: 0 to 1, already eased by the effect's easing. - uniform vec2 uResolution: the box in device pixels. - uniform vec2 uFromSize, uToSize: natural photo sizes in pixels. - uniform float uTime: seconds since the lightbox opened. - uniform float uDirection: +1 next, -1 previous. - Your uniforms: number -> float, [a, b] -> vec2, [a, b, c] -> vec3, [a, b, c, d] -> vec4. Declare them with the same names. - Predefined macros: coverUv(uv, texSize, resolution) cover-fits; mirrorUv(uv) mirrors into 0 to 1 (no edge smear). - The lightbox clips to the rounded box and runs the open and close spring itself. - Compile errors: onError({ reason: "shader", message }) and the lightbox uses the CSS renderer. Never a blank screen. - Reduced motion replaces every effect with a fade. - Effect object: { fragment, uniforms?, duration? (ms, default 480), easing? ("ease-out" default | "ease-in-out" | "linear" | (t) => number), css? ("fade" | "slide" | "zoom" | "none") }. - Shaders run on the GPU with no network or DOM access. Only use shaders you trust; never compile user-supplied shader source. --- 5.11 Videos mixed with photos --- unfurl({ photos: [ { src: "/img/a.jpg", alt: "Terrace" }, { type: "video", provider: "youtube", src: "https://youtu.be/dQw4w9WgXcQ", poster: "/img/walk.jpg", title: "Pool walk" }, { type: "video", provider: "vimeo", src: "https://vimeo.com/76979871", poster: "/img/v.jpg" }, { type: "video", provider: "tiktok", src: "https://www.tiktok.com/@you/video/7212345678901234567", poster: "/img/tt.jpg" }, { type: "video", provider: "instagram", src: "https://www.instagram.com/reel/C1a2B3c4D5e/", poster: "/img/ig.jpg" }, { type: "video", provider: "mp4", src: "/media/tour.mp4", poster: "/img/tour.jpg" }, ], origin: (n) => thumbs[n], // the poster springs from the grid }); // Players load only on tap: youtube-nocookie, Vimeo dnt=1, official TikTok and Instagram embeds,