Components
Lightbox
Lightbox custom element combining an inline gallery, fullscreen dialog, and synchronized thumbnails.
<ui-lightbox> coordinates an inline <ui-carousel> gallery with a fullscreen <dialog> carousel. Slides open the dialog using command="show-modal". The component syncs slide indices between the inline and fullscreen carousels.
Default
Loading components…
<ui-lightbox class="max-w-screen-xs mx-auto"> <div data-slot="lightbox-gallery"> <ui-carousel data-slot="lightbox-stage" data-carousel-loop="true" data-carousel-plugins="class-names" > <div data-slot="carousel-viewport"> <div data-slot="carousel-container"> <button data-slot="lightbox-slide carousel-slide" type="button" command="show-modal" commandfor="lightbox-example-1" > <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop 800w " alt="Mountain landscape" loading="lazy" width="800" height="600" /> </figure> </button> <button data-slot="lightbox-slide carousel-slide" type="button" command="show-modal" commandfor="lightbox-example-1" > <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800&h=600&fit=crop 800w " alt="Valley with sunlight" loading="lazy" width="800" height="600" /> </figure> </button> <button data-slot="lightbox-slide carousel-slide" type="button" command="show-modal" commandfor="lightbox-example-1" > <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=800&h=600&fit=crop 800w " alt="Forest path" loading="lazy" width="800" height="600" /> </figure> </button> <button data-slot="lightbox-slide carousel-slide" type="button" command="show-modal" commandfor="lightbox-example-1" > <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=800&h=600&fit=crop 800w " alt="Waterfall in forest" loading="lazy" width="800" height="600" /> </figure> </button> <button data-slot="lightbox-slide carousel-slide" type="button" command="show-modal" commandfor="lightbox-example-1" > <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=800&h=600&fit=crop 800w " alt="Lake at sunset" loading="lazy" width="800" height="600" /> </figure> </button> <button data-slot="lightbox-slide carousel-slide" type="button" command="show-modal" commandfor="lightbox-example-1" > <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=800&h=600&fit=crop 800w " alt="Foggy forest" loading="lazy" width="800" height="600" /> </figure> </button> </div> </div> <div data-slot="lightbox-thumbs carousel-thumbs" data-carousel-loop="true"> <button class="ui-button text-muted-foreground hover:text-foreground" data-slot="lightbox-thumbs-prev carousel-prev" data-size="icon-sm" data-variant="ghost" type="button" aria-label="Previous image" > <svg class="size-sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true" > <rect width="256" height="256" fill="none" /> <polyline points="160 208 80 128 160 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="14" /> </svg> </button> <div data-slot="carousel-viewport"> <div data-slot="carousel-container"> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 1" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=456&h=342&fit=crop 456w " alt="Mountain landscape" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 2" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=456&h=342&fit=crop 456w " alt="Valley with sunlight" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 3" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=456&h=342&fit=crop 456w " alt="Forest path" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 4" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=456&h=342&fit=crop 456w " alt="Waterfall in forest" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 5" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=456&h=342&fit=crop 456w " alt="Lake at sunset" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 6" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=456&h=342&fit=crop 456w " alt="Foggy forest" loading="lazy" width="800" height="600" /> </figure> </button> </div> </div> <button class="ui-button text-muted-foreground hover:text-foreground" data-slot="lightbox-thumbs-next carousel-next" data-size="icon-sm" data-variant="ghost" type="button" aria-label="Next image" > <svg class="size-sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true" > <rect width="256" height="256" fill="none" /> <polyline points="96 48 176 128 96 208" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="14" /> </svg> </button> </div> </ui-carousel> </div> <dialog id="lightbox-example-1" class="ui-dialog dark" data-slot="lightbox-dialog" data-size="screen" closedby="any" > <div data-slot="dialog-content"> <ui-carousel data-carousel-loop="true" data-carousel-plugins="class-names"> <div data-slot="carousel-viewport"> <div data-slot="carousel-container"> <div data-slot="lightbox-slide carousel-slide"> <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop 800w " alt="Mountain landscape" loading="lazy" width="800" height="600" /> </figure> </div> <div data-slot="lightbox-slide carousel-slide"> <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800&h=600&fit=crop 800w " alt="Valley with sunlight" loading="lazy" width="800" height="600" /> </figure> </div> <div data-slot="lightbox-slide carousel-slide"> <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=800&h=600&fit=crop 800w " alt="Forest path" loading="lazy" width="800" height="600" /> </figure> </div> <div data-slot="lightbox-slide carousel-slide"> <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=800&h=600&fit=crop 800w " alt="Waterfall in forest" loading="lazy" width="800" height="600" /> </figure> </div> <div data-slot="lightbox-slide carousel-slide"> <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=800&h=600&fit=crop 800w " alt="Lake at sunset" loading="lazy" width="800" height="600" /> </figure> </div> <div data-slot="lightbox-slide carousel-slide"> <figure data-slot="lightbox-content"> <img sizes="(max-width: 40rem) 100vw, 800px" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=800&h=600&fit=crop" srcset=" https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=456&h=342&fit=crop 456w, https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=800&h=600&fit=crop 800w " alt="Foggy forest" loading="lazy" width="800" height="600" /> </figure> </div> </div> </div> <button class="ui-button size-auto text-muted-foreground hover:text-foreground" data-slot="lightbox-prev carousel-prev" data-size="icon" data-variant="ghost" type="button" aria-label="Previous image" > <svg class="size-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <polyline points="160 208 80 128 160 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="14" /> </svg> </button> <button class="ui-button size-auto text-muted-foreground hover:text-foreground" data-slot="lightbox-next carousel-next" data-size="icon" data-variant="ghost" type="button" aria-label="Next image" > <svg class="size-md" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <polyline points="96 48 176 128 96 208" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="14" /> </svg> </button> <div data-slot="lightbox-thumbs carousel-thumbs"> <div data-slot="carousel-viewport"> <div data-slot="carousel-container"> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 1" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=456&h=342&fit=crop 456w " alt="Mountain landscape" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 2" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=456&h=342&fit=crop 456w " alt="Valley with sunlight" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 3" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=456&h=342&fit=crop 456w " alt="Forest path" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 4" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1433086966358-54859d0ed716?w=456&h=342&fit=crop 456w " alt="Waterfall in forest" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 5" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=456&h=342&fit=crop 456w " alt="Lake at sunset" loading="lazy" width="800" height="600" /> </figure> </button> <button type="button" data-slot="lightbox-thumb carousel-slide" aria-label="Show image 6" > <figure data-slot="lightbox-thumb-content"> <img sizes="auto" loading="lazy" width="228" height="171" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=228&h=171&fit=crop" srcset=" https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=228&h=171&fit=crop 228w, https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=456&h=342&fit=crop 456w " alt="Foggy forest" loading="lazy" width="800" height="600" /> </figure> </button> </div> </div> </div> </ui-carousel> </div> <button class="ui-button" data-slot="lightbox-close" data-size="icon" data-variant="ghost" type="button" commandfor="lightbox-example-1" command="close" aria-label="Close lightbox" > <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"> <rect width="256" height="256" fill="none" /> <line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> <line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" /> </svg> </button> </dialog></ui-lightbox>// primitives/carousel/carousel.js"use strict";/** * @fileoverview `<ui-carousel>` — HTML web component for carousels. * @description Light-DOM custom element that wraps standard carousel markup * and owns the Embla lifecycle: it initializes on connect (via * `EmblaInit.initRoot`) and destroys its instances on disconnect, so * dynamically inserted or SPA-swapped carousels need no manual wiring. * * The element *is* the carousel root — CSS and `embla.js` target * `:is(ui-carousel, .ui-carousel)` directly, and `data-carousel-*` * configuration attributes are read off the element (see embla.js for the * full attribute reference). No shadow DOM; children are regular markup. * * Carousels inside a closed `<dialog>` defer initialization until the dialog * first opens (a closed dialog is `display: none`, so Embla cannot measure * the viewport). * * Load order: the module graph resolves it — `index.js` imports embla.js * (which imports the Embla packages via the page's import map) before this file. * * @example * <ui-carousel data-carousel-loop="true"> * <div data-slot="carousel-viewport"> * <div data-slot="carousel-container"> * <div data-slot="carousel-slide">Slide 1</div> * <div data-slot="carousel-slide">Slide 2</div> * </div> * </div> * <button type="button" data-slot="carousel-prev">Prev</button> * <button type="button" data-slot="carousel-next">Next</button> * </ui-carousel> */import { EmblaInit } from "../../base/embla.js";import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";class UiCarouselElement extends ZazzElement { setup(signal) { const dialog = this.closest("dialog"); if (dialog && !dialog.open) { // Closed dialogs are display:none — Embla can't measure the viewport. // Initialize on the dialog's open instead (zazz:dialog-open, ADR-0003). dialog.addEventListener("zazz:dialog-open", () => this.init(), { signal }); return; } this.init(); } teardown() { // Abort first so the per-carousel DOM listeners (prev/next, dots, thumbs, // drag-click suppression) are removed before the Embla instances are torn down. this._emblaController?.abort(); delete this._emblaController; this._emblaApi?.destroy(); this._emblaApiThumb?.destroy(); delete this._emblaApi; delete this._emblaApiThumb; // Allow re-initialization if the element is re-inserted. this.removeAttribute("data-carousel-init"); } /** * @description Initializes the carousel. Idempotent — already-initialized * roots and roots inside closed dialogs are skipped by `initRoot`. */ init() { EmblaInit.initRoot(this); } /** * @returns The Embla API, or null before initialization. */ get api() { return this._emblaApi ?? null; }}defineZazzElement("ui-carousel", UiCarouselElement);export { UiCarouselElement };// primitives/lightbox/lightbox.js"use strict";/** * @fileoverview `<ui-lightbox>` — HTML web component for lightbox galleries. * @description Light-DOM custom element that coordinates the two carousels in * a lightbox: the inline gallery and the fullscreen `<dialog>` slideshow. * * Responsibilities (subscribing to `zazz:dialog-open` / `zazz:dialog-close` * from base/dialog-lifecycle.ts — ADR-0003): * - On dialog open: initializes the dialog's `<ui-carousel>` (deferred * while the dialog was closed), jumps it to the gallery's current slide, * and focuses the viewport so keyboard navigation works immediately. * - On dialog close: scrolls the inline gallery to the last viewed slide. * * Opening and closing the dialog itself needs no JavaScript — slides carry * `command="show-modal"` / `command="close"` (Invoker Commands). Drag-aware * click suppression on the stage and thumbs is wired by `initRoot` in * embla.js (keyed generically on command-bearing `carousel-slide` slots). * * Load order: the module graph resolves it — `index.js` imports embla.js and * carousel.js before this file; Embla itself resolves via the page's import map. * * @example * <ui-lightbox> * <div data-slot="lightbox-gallery"> * <ui-carousel data-slot="lightbox-stage" data-carousel-loop="true">…</ui-carousel> * </div> * <dialog class="dialog" data-slot="lightbox-dialog" closedby="any"> * <ui-carousel data-carousel-loop="true">…</ui-carousel> * </dialog> * </ui-lightbox> */import { UiCarouselElement } from "../carousel/carousel.js";import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";class UiLightbox extends ZazzElement { setup(signal) { const dialog = this.querySelector("dialog"); if (!(dialog instanceof HTMLDialogElement)) return; // The dialog is a descendant, so its lifecycle events (ADR-0003) bubble // through this element — subscribe here instead of observing attributes. this.addEventListener("zazz:dialog-open", (e) => { if (e.target === dialog) this.#onDialogOpen(dialog); }, { signal }); this.addEventListener("zazz:dialog-close", (e) => { if (e.target === dialog) this.#syncGalleryToDialog(dialog); }, { signal }); } /** * @returns The inline gallery's carousel root. */ #galleryRoot() { return this.querySelector('[data-slot~="lightbox-gallery"] :is(ui-carousel, .ui-carousel)'); } /** * @description Initializes the dialog carousel, opens it at the gallery's * current slide, and moves focus to the slideshow viewport. * * @param dialog - The lightbox dialog. */ #onDialogOpen(dialog) { const dialogRoot = dialog.querySelector(":is(ui-carousel, .ui-carousel)"); if (!dialogRoot) return; // <ui-carousel> defers init while its dialog is closed. Its own // zazz:dialog-open listener (on the dialog) fires before this one (on the // ancestor) by DOM dispatch order, but init here too — idempotent — so the // jump below never races a class-form root. if (dialogRoot instanceof UiCarouselElement) { dialogRoot.init(); } const galleryApi = this.#galleryRoot()?._emblaApi; if (dialogRoot._emblaApi && galleryApi) { dialogRoot._emblaApi.scrollTo(galleryApi.selectedScrollSnap(), true); } const viewport = dialogRoot.querySelector('[data-slot~="carousel-viewport"]'); if (viewport instanceof HTMLElement) { viewport.focus({ preventScroll: true }); } } /** * @description Scrolls the inline gallery to the slide last viewed in the dialog. * * @param dialog - The lightbox dialog. */ #syncGalleryToDialog(dialog) { const dialogApi = dialog.querySelector(":is(ui-carousel, .ui-carousel)")?._emblaApi; const galleryApi = this.#galleryRoot()?._emblaApi; if (dialogApi && galleryApi) { galleryApi.scrollTo(dialogApi.selectedScrollSnap()); } }}defineZazzElement("ui-lightbox", UiLightbox);export { UiLightbox };/** * lightbox.css — Lightbox gallery (ui-lightbox | .ui-lightbox) * * @layer variables, components * @requires layers.css, _variables.css, dialog.css, carousel.css, button.css * @uses command="show-modal" — Invoker Commands open fullscreen dialog (no JS) * @uses @container — thumb column count at narrow widths * @uses @starting-style — dialog slide scale/opacity on snap * @uses Embla sync — requires zazz/scripts/embla.js for carousel behaviour * @tokens --ui-lightbox-* (@layer variables) */@layer variables { :root { --ui-lightbox-aspect-ratio: 3 / 2; --ui-lightbox-object-fit: cover; --ui-lightbox-radius: var(--radius-md); --ui-lightbox-slide-radius: var(--radius-lg); --ui-lightbox-thumb-radius: var(--radius-md); --ui-lightbox-thumb-size: var(--step-16); --ui-lightbox-thumb-border: var(--border); --ui-lightbox-thumb-border--active: var(--primary); --ui-lightbox-thumb-opacity: 0.5; --ui-lightbox-thumb-opacity--active: 1; --ui-lightbox-thumb-columns: 6; --ui-lightbox-thumb-gap: var(--step-1_5); --ui-lightbox-thumb-aspect-ratio: 1 / 1; --ui-lightbox-img-border: 1px solid var(--faded); /* Reveal transitions/animations */ --ui-lightbox-slide-opacity--start: 0; --ui-lightbox-slide-opacity--end: 1; --ui-lightbox-slide-scale--start: 0.9; --ui-lightbox-slide-scale--end: 1; --ui-lightbox-img-scale--hover: 1.03; --ui-lightbox-img-filter--hover: brightness(0.8); --ui-lightbox-img-scale--active: 1; --ui-lightbox-img-filter--active: brightness(0.5); --ui-lightbox-img-transition-property: transform, filter; --ui-lightbox-img-transition-duration: calc(var(--default-transition-duration) * 3); --ui-lightbox-img-transition-timing-function: var(--default-transition-timing-function); --ui-lightbox-img-transition-delay: 0ms; }}@layer zazz.components { /* =========================================================================== LIGHTBOX — image gallery with fullscreen dialog slideshow - Inline gallery: main Embla carousel + linked thumb strip. - Gallery slides are <button command="show-modal"> — opens dialog at current index. - Dialog repeats main carousel + thumbs with prev/next navigation. - No JavaScript required for open/close — uses Invoker Commands API. =========================================================================== */ /* --------------------------------------------------------------------------- GALLERY — :where(ui-lightbox, .ui-lightbox) --------------------------------------------------------------------------- */ :where(ui-lightbox, .ui-lightbox) { display: flex; flex-direction: column; } @container section (width <= 40rem) { :where(ui-lightbox, .ui-lightbox) { --ui-lightbox-thumb-columns: 4; } } /* --------------------------------------------------------------------------- INLINE GALLERY — [data-slot~="lightbox-gallery"] / [data-slot~="lightbox-stage"] --------------------------------------------------------------------------- */ [data-slot~="lightbox-gallery"] { inline-size: 100%; } [data-slot~="lightbox-stage"] { position: relative; display: flex; flex-direction: column; gap: calc(var(--ui-lightbox-thumb-gap) * 2); } [data-slot~="lightbox-stage"] > [data-slot~="carousel-viewport"] { border-radius: var(--ui-lightbox-slide-radius); background: var(--card); } [data-slot~="lightbox-stage"] > [data-slot~="carousel-viewport"] > [data-slot~="carousel-container"] { border-radius: var(--ui-lightbox-slide-radius); margin-inline-start: calc(var(--gap-sm) * -1); } [data-slot~="lightbox-stage"] [data-slot~="lightbox-slide"] { appearance: none; border: none; background: none; margin: 0; display: flex; align-items: center; justify-content: center; block-size: 100%; cursor: zoom-in; overflow: clip; padding-inline-start: var(--gap-sm); } [data-slot~="lightbox-stage"] [data-slot~="lightbox-content"] { --_ring-width: 0px; aspect-ratio: var(--ui-lightbox-aspect-ratio); inline-size: 100%; block-size: 100%; overflow: clip; position: relative; border-radius: var(--ui-lightbox-slide-radius); border: var(--ui-lightbox-img-border); /* transparent twin of the inset ring for forced-colors / high-contrast modes */ outline: var(--outline-width) var(--outline-style) transparent; outline-offset: calc(var(--outline-width) * -1); } /* The inset ring paints on an overlay so it sits above the slide image (an inset box-shadow on the figure itself would hide behind the img); the ring width animates from 0 so the ring grows in smoothly on focus. */ [data-slot~="lightbox-stage"] [data-slot~="lightbox-content"]::after { content: ""; position: absolute; inset: 0; pointer-events: none; border-radius: inherit; box-shadow: inset 0 0 0 var(--_ring-width) var(--ring-color); transition: box-shadow var(--default-transition-duration) var(--default-transition-timing-function); } [data-slot~="lightbox-stage"] [data-slot~="lightbox-slide"]:focus-visible { outline: none; } [data-slot~="lightbox-stage"] [data-slot~="lightbox-slide"]:focus-visible [data-slot~="lightbox-content"] { --_ring-width: var(--ring-width); outline-color: transparent; } [data-slot~="lightbox-stage"] [data-slot~="lightbox-slide"] img { aspect-ratio: var(--ui-lightbox-aspect-ratio); inline-size: 100%; block-size: 100%; object-fit: var(--ui-lightbox-object-fit); user-select: none; -webkit-user-drag: none; pointer-events: none; transition-property: var(--ui-lightbox-img-transition-property); transition-duration: var(--ui-lightbox-img-transition-duration); transition-timing-function: var(--ui-lightbox-img-transition-timing-function); transition-delay: var(--ui-lightbox-img-transition-delay); transform: scale(var(--ui-lightbox-img-scale)); opacity: var(--ui-lightbox-img-opacity); } [data-slot~="lightbox-stage"] [data-slot~="lightbox-slide"]:hover img { transform: scale(var(--ui-lightbox-img-scale--hover)); filter: var(--ui-lightbox-img-filter--hover); } [data-slot~="lightbox-stage"] [data-slot~="lightbox-slide"]:active img { transform: scale(var(--ui-lightbox-img-scale--active)); filter: var(--ui-lightbox-img-filter--active); } /* --------------------------------------------------------------------------- thumbs — [data-slot~="lightbox-thumbs"] / [data-slot~="lightbox-thumb"] --------------------------------------------------------------------------- */ [data-slot~="lightbox-thumbs"] { position: relative; z-index: 2; } [data-slot~="lightbox-stage"] [data-slot~="lightbox-thumbs"] { display: flex; align-items: center; gap: var(--ui-lightbox-thumb-gap); } [data-slot~="lightbox-stage"] [data-slot~="lightbox-thumbs"] [data-slot~="carousel-viewport"] { flex: 1; min-inline-size: 0; border-radius: var(--ui-lightbox-thumb-radius); } [data-slot~="lightbox-thumbs-prev"], [data-slot~="lightbox-thumbs-next"] { flex-shrink: 0; } [data-slot~="lightbox-thumbs"] [data-slot~="carousel-container"] { gap: var(--ui-lightbox-thumb-gap); } /* Gallery thumbs: fill track evenly, centered, max 6 columns */ [data-slot~="lightbox-stage"] [data-slot~="lightbox-thumbs"] [data-slot~="carousel-container"] { justify-content: start; } [data-slot~="lightbox-stage"] [data-slot~="lightbox-thumb"] { max-inline-size: calc( (100% - (var(--ui-lightbox-thumb-gap) * (var(--ui-lightbox-thumb-columns) - 1))) / var(--ui-lightbox-thumb-columns) ); } [data-slot~="lightbox-thumb-content"] { --_ring-width: 0px; --_ring: var(--ring-color); appearance: none; cursor: pointer; position: relative; border: var(--ui-lightbox-img-border); border-radius: var(--ui-lightbox-thumb-radius); overflow: clip; /* transparent twin of the inset ring for forced-colors / high-contrast modes */ outline: var(--outline-width) var(--outline-style) transparent; outline-offset: calc(var(--outline-width) * -1); } /* The inset ring paints on an overlay so it sits above the thumb image (an inset box-shadow on the button itself would hide behind the img). */ [data-slot~="lightbox-thumb-content"]::after { content: ""; position: absolute; inset: 0; pointer-events: none; border-radius: inherit; box-shadow: inset 0 0 0 var(--_ring-width) var(--_ring); transition: box-shadow var(--default-transition-duration) var(--default-transition-timing-function); } [data-slot~="lightbox-thumb"] img { opacity: var(--ui-lightbox-thumb-opacity); transition: opacity var(--default-transition-duration) var(--default-transition-timing-function), border-color var(--default-transition-duration) var(--default-transition-timing-function), transform var(--default-transition-duration) var(--default-transition-timing-function); inline-size: 100%; block-size: 100%; object-fit: cover; } [data-slot~="lightbox-thumb"]:hover img { opacity: var(--ui-lightbox-thumb-opacity--active); } [data-slot~="lightbox-thumb"].is-active img { transform: scale(1.1); opacity: var(--ui-lightbox-thumb-opacity--active); } [data-slot~="lightbox-thumb"]:focus-visible { outline: none; } [data-slot~="lightbox-thumb"]:focus-visible [data-slot~="lightbox-thumb-content"] { --_ring-width: var(--ring-width); outline-color: transparent; } [data-slot~="lightbox-thumb"]:focus-visible img { transform: scale(1); } /* --------------------------------------------------------------------------- DIALOG — [data-slot~="lightbox-dialog"] (fullscreen dark overlay) --------------------------------------------------------------------------- */ [data-slot~="lightbox-dialog"] { --ui-lightbox-thumb-radius: var(--radius-sm); --ui-dialog-background: transparent; --ui-dialog-border: none; overflow: clip; pointer-events: none; outline: none; } [data-slot~="lightbox-dialog"] [data-slot~="lightbox-thumb"] img { border-radius: var(--ui-lightbox-thumb-radius); } [data-slot~="lightbox-dialog"] [data-slot~="dialog-content"] { grid-template-rows: 1fr; max-block-size: 100svh; block-size: 100svh; overflow: clip; } [data-slot~="lightbox-dialog"] :where(ui-carousel, .ui-carousel) { display: grid; grid-template-rows: 1fr auto; block-size: 100%; max-block-size: 100svh; } [data-slot~="lightbox-dialog"] [data-slot~="carousel-container"] { block-size: 100%; } [data-slot~="lightbox-dialog"] [data-slot~="carousel-viewport"] { outline: none; } [data-slot~="lightbox-dialog"] :where(ui-carousel, .ui-carousel) > [data-slot~="carousel-viewport"]:first-of-type { min-block-size: 0; } [data-slot~="lightbox-dialog"] [data-slot~="lightbox-thumbs"] { padding-block: var(--gap-sm); pointer-events: auto; } [data-slot~="lightbox-dialog"] [data-slot~="lightbox-thumbs"] [data-slot~="carousel-container"] { justify-content: center; padding-block: var(--gap-xs); } [data-slot~="lightbox-dialog"] [data-slot~="lightbox-thumb"] { --ui-lightbox-thumb-border: var(--tint-200); --ui-lightbox-thumb-border--active: var(--ring); flex: 0 0 var(--ui-lightbox-thumb-size); inline-size: var(--ui-lightbox-thumb-size); } [data-slot~="lightbox-dialog"] [data-slot~="lightbox-thumb"]:focus-visible [data-slot~="lightbox-thumb-content"] { --_ring: var(--foreground); } /* --------------------------------------------------------------------------- SLIDE — [data-slot~="lightbox-slide"] (dialog full-size slides) --------------------------------------------------------------------------- */ [data-slot~="lightbox-dialog"] [data-slot~="lightbox-slide"] { display: flex; align-items: center; justify-content: center; min-block-size: 0; min-inline-size: 0; padding: clamp(var(--gap-sm), 4vi, var(--gap-lg)); } [data-slot~="lightbox-dialog"] [data-slot~="lightbox-content"] { display: flex; align-items: center; justify-content: center; block-size: 100%; inline-size: 100%; min-block-size: 0; min-inline-size: 0; max-block-size: 100%; max-inline-size: 100%; } [data-slot~="lightbox-dialog"] [data-slot~="lightbox-slide"] img { --ui-lightbox-thumb-radius: var(--radius-xs); border-radius: var(--ui-lightbox-slide-radius); border: var(--ui-lightbox-img-border); overflow: clip; inline-size: 100%; block-size: 100%; max-block-size: fit-content; max-inline-size: fit-content; object-fit: contain; user-select: none; -webkit-user-drag: none; transition: opacity var(--default-transition-duration) var(--default-transition-timing-function), transform var(--default-transition-duration) var(--default-transition-timing-function); opacity: var(--ui-lightbox-slide-opacity--start); transform: scale(var(--ui-lightbox-slide-scale--start)); pointer-events: auto; } [data-slot~="lightbox-dialog"] [data-slot~="lightbox-slide"].is-in-view.is-snapped img { opacity: var(--ui-lightbox-slide-opacity--end); transform: scale(var(--ui-lightbox-slide-scale--end)); @starting-style { opacity: 0; transform: scale(var(--ui-lightbox-slide-scale--start)); } } /* --------------------------------------------------------------------------- NAV BUTTONS — [data-slot~="lightbox-prev"] / [data-slot~="lightbox-next"] (icon-size .ui-button) --------------------------------------------------------------------------- */ [data-slot~="lightbox-prev"], [data-slot~="lightbox-next"] { --ui-button-ring-color: var(--foreground); --ring-opacity: 80%; pointer-events: auto; position: absolute; top: 50%; transform: translateY(-50%); z-index: 50; } [data-slot~="lightbox-prev"] { left: var(--gap-md); } [data-slot~="lightbox-next"] { right: var(--gap-md); } /* --------------------------------------------------------------------------- CLOSE BUTTON — top-right --------------------------------------------------------------------------- */ [data-slot~="lightbox-close"] { pointer-events: auto; position: absolute; top: var(--gap-sm); right: var(--gap-sm); z-index: 10; } /* --------------------------------------------------------------------------- COUNTER — slide count indicator (optional) --------------------------------------------------------------------------- */ [data-slot~="lightbox-counter"] { position: absolute; bottom: var(--gap-sm); left: 50%; translate: -50% 0; opacity: 0.7; font-size: var(--font-size-sm); font-variant-numeric: tabular-nums; pointer-events: none; }}Markup structure
| Markup | Placement | Purpose |
|---|---|---|
<ui-lightbox> | Root | Coordinates gallery and modal state |
<ui-carousel data-slot="lightbox-stage"> | Gallery | Inline carousel with drag and thumbnail support |
<dialog class="ui-dialog" data-slot="lightbox-dialog"> | Root | Fullscreen modal container |
<ui-carousel> | Dialog | Fullscreen carousel initialized when modal opens |
command="show-modal" / command="close" | Triggers | Opens and closes dialog using Invoker Commands |
data-slot="carousel-viewport" / "carousel-container" / "carousel-thumbs" | Children | Embla layout slots |
data-slot="lightbox-slide carousel-slide" | Slides | Slide serving both primitives (see below) |
Because data-slot is a space-separated token list, each slide carries two slots at once: the lightbox styles and syncs it as lightbox-slide, while Embla lays it out as carousel-slide. The same pattern applies to thumbnails (data-slot="lightbox-thumb carousel-slide") and the thumbnail strip (data-slot="lightbox-thumbs carousel-thumbs").