API
Lightbox
A fullscreen gallery dialog built on the carousel.
Installation
pnpm dlx zazz-ui add lightboxnpx zazz-ui add lightboxyarn dlx zazz-ui add lightboxbunx zazz-ui add lightboxAlso adds fields, kbd, button, dialog, carousel, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
<ui-lightbox
style="
--mx: auto;
--max-w: var(--article-sm);
"
>
<div data-lightbox-slot="gallery">
<ui-carousel data-lightbox-slot="stage" data-carousel-loop="true">
<div data-carousel-slot="viewport">
<div data-carousel-slot="container">
<button
data-lightbox-slot="slide"
data-carousel-slot="slide"
type="button"
command="show-modal"
commandfor="lightbox-example-1"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
data-lightbox-slot="slide"
data-carousel-slot="slide"
type="button"
command="show-modal"
commandfor="lightbox-example-1"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
data-lightbox-slot="slide"
data-carousel-slot="slide"
type="button"
command="show-modal"
commandfor="lightbox-example-1"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
data-lightbox-slot="slide"
data-carousel-slot="slide"
type="button"
command="show-modal"
commandfor="lightbox-example-1"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
data-lightbox-slot="slide"
data-carousel-slot="slide"
type="button"
command="show-modal"
commandfor="lightbox-example-1"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
data-lightbox-slot="slide"
data-carousel-slot="slide"
type="button"
command="show-modal"
commandfor="lightbox-example-1"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
</div>
</div>
<div data-lightbox-slot="thumbs" data-carousel-slot="thumbs" data-carousel-loop="true">
<button
data-ui="button"
data-lightbox-slot="thumbs-prev"
data-carousel-slot="prev"
data-button-size="icon-sm"
data-button-variant="ghost"
type="button"
aria-label="Previous image"
style="
--text: var(--color-muted-foreground);
--text--hover: var(--color-foreground);
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
aria-hidden="true"
style="--size: 4"
>
<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-carousel-slot="viewport">
<div data-carousel-slot="container">
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 1"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 2"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 3"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 4"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 5"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 6"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
</div>
</div>
<button
data-ui="button"
data-lightbox-slot="thumbs-next"
data-carousel-slot="next"
data-button-size="icon-sm"
data-button-variant="ghost"
type="button"
aria-label="Next image"
style="
--text: var(--color-muted-foreground);
--text--hover: var(--color-foreground);
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
aria-hidden="true"
style="--size: 4"
>
<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"
data-ui="dialog theme-dark"
data-lightbox-slot="dialog"
data-dialog-size="screen"
closedby="any"
>
<div data-dialog-slot="content">
<ui-carousel data-carousel-loop="true">
<div data-carousel-slot="viewport">
<div data-carousel-slot="container">
<div data-lightbox-slot="slide" data-carousel-slot="slide">
<figure data-lightbox-slot="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"
/>
</figure>
</div>
<div data-lightbox-slot="slide" data-carousel-slot="slide">
<figure data-lightbox-slot="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"
/>
</figure>
</div>
<div data-lightbox-slot="slide" data-carousel-slot="slide">
<figure data-lightbox-slot="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"
/>
</figure>
</div>
<div data-lightbox-slot="slide" data-carousel-slot="slide">
<figure data-lightbox-slot="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"
/>
</figure>
</div>
<div data-lightbox-slot="slide" data-carousel-slot="slide">
<figure data-lightbox-slot="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"
/>
</figure>
</div>
<div data-lightbox-slot="slide" data-carousel-slot="slide">
<figure data-lightbox-slot="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"
/>
</figure>
</div>
</div>
</div>
<button
data-ui="button"
data-lightbox-slot="prev"
data-carousel-slot="prev"
data-button-size="icon"
data-button-variant="ghost"
type="button"
aria-label="Previous image"
style="
--w: auto;
--h: auto;
--text: var(--color-muted-foreground);
--text--hover: var(--color-foreground);
"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" style="--size: 6">
<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
data-ui="button"
data-lightbox-slot="next"
data-carousel-slot="next"
data-button-size="icon"
data-button-variant="ghost"
type="button"
aria-label="Next image"
style="
--w: auto;
--h: auto;
--text: var(--color-muted-foreground);
--text--hover: var(--color-foreground);
"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" style="--size: 6">
<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-lightbox-slot="thumbs" data-carousel-slot="thumbs">
<div data-carousel-slot="viewport">
<div data-carousel-slot="container">
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 1"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 2"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 3"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 4"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 5"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
<button
type="button"
data-lightbox-slot="thumb"
data-carousel-slot="slide"
aria-label="Show image 6"
>
<figure data-lightbox-slot="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"
/>
</figure>
</button>
</div>
</div>
</div>
</ui-carousel>
</div>
<button
data-ui="button"
data-lightbox-slot="close"
data-button-size="icon"
data-button-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>/**
* lightbox.css — Lightbox gallery (ui-lightbox | [data-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: 3 / 2;
--ui-lightbox-object-fit: cover;
--ui-lightbox-slide-rounded: var(--radius-lg);
--ui-lightbox-thumb-rounded: var(--radius-md);
--ui-lightbox-thumb-size: calc(var(--spacing) * 16);
--ui-lightbox-thumb-opacity: 0.5;
--ui-lightbox-thumb-opacity--active: 1;
--ui-lightbox-thumb-columns: 6;
--ui-lightbox-thumb-gap: calc(var(--spacing) * 1.5);
--ui-lightbox-img-border: 1px solid var(--color-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: 1;
--ui-lightbox-img-opacity: 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 ui.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, [data-ui~="lightbox"])
--------------------------------------------------------------------------- */
:where(ui-lightbox, [data-ui~="lightbox"]) {
display: flex;
flex-direction: column;
}
@container (width <= 40rem) {
:where(ui-lightbox, [data-ui~="lightbox"]) {
--ui-lightbox-thumb-columns: 4;
}
}
/* ---------------------------------------------------------------------------
INLINE GALLERY — [data-lightbox-slot~="gallery"] / [data-lightbox-slot~="stage"]
--------------------------------------------------------------------------- */
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="gallery"] {
inline-size: 100%;
}
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="stage"] {
position: relative;
display: flex;
flex-direction: column;
gap: calc(var(--ui-lightbox-thumb-gap) * 2);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
> [data-carousel-slot~="viewport"] {
border-radius: var(--ui-lightbox-slide-rounded);
background: var(--color-card);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
> [data-carousel-slot~="viewport"]
> [data-carousel-slot~="container"] {
border-radius: var(--ui-lightbox-slide-rounded);
margin-inline-start: calc(var(--space-sm) * -1);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="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(--space-sm);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="content"] {
--_ring-width: 0px;
aspect-ratio: var(--ui-lightbox-aspect);
inline-size: 100%;
block-size: 100%;
overflow: clip;
position: relative;
border-radius: var(--ui-lightbox-slide-rounded);
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. */
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="content"]::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
box-shadow: inset 0 0 0 var(--_ring-width) var(--color-ring);
transition: box-shadow var(--default-transition-duration)
var(--default-transition-timing-function);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="slide"]:focus-visible {
outline: none;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="slide"]:focus-visible
[data-lightbox-slot~="content"] {
--_ring-width: var(--ring-width);
outline-color: transparent;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="slide"]
img {
aspect-ratio: var(--ui-lightbox-aspect);
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);
}
@media (hover: hover) {
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="slide"]:hover
img {
transform: scale(var(--ui-lightbox-img-scale--hover));
filter: var(--ui-lightbox-img-filter--hover);
}
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="slide"]:active
img {
transform: scale(var(--ui-lightbox-img-scale--active));
filter: var(--ui-lightbox-img-filter--active);
}
/* ---------------------------------------------------------------------------
thumbs — [data-lightbox-slot~="thumbs"] / [data-lightbox-slot~="thumb"]
--------------------------------------------------------------------------- */
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="thumbs"] {
position: relative;
z-index: 2;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="thumbs"] {
display: flex;
align-items: center;
gap: var(--ui-lightbox-thumb-gap);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="thumbs"]
[data-carousel-slot~="viewport"] {
flex: 1;
min-inline-size: 0;
border-radius: var(--ui-lightbox-thumb-rounded);
}
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="thumbs-prev"],
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="thumbs-next"] {
flex-shrink: 0;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="thumbs"]
[data-carousel-slot~="container"] {
gap: var(--ui-lightbox-thumb-gap);
}
/* Gallery thumbs: fill track evenly, centered, max 6 columns */
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="thumbs"]
[data-carousel-slot~="container"] {
justify-content: start;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="stage"]
[data-lightbox-slot~="thumb"] {
max-inline-size: calc(
(100% - (var(--ui-lightbox-thumb-gap) * (var(--ui-lightbox-thumb-columns) - 1))) /
var(--ui-lightbox-thumb-columns)
);
}
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="thumb-content"] {
--_ring-width: 0px;
--_ring-fill: var(--color-ring);
appearance: none;
cursor: pointer;
position: relative;
border: var(--ui-lightbox-img-border);
border-radius: var(--ui-lightbox-thumb-rounded);
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). */
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="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-fill);
transition: box-shadow var(--default-transition-duration)
var(--default-transition-timing-function);
}
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="thumb"] img {
opacity: var(--ui-lightbox-thumb-opacity);
transition:
opacity 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;
}
@media (hover: hover) {
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="thumb"]:hover img {
opacity: var(--ui-lightbox-thumb-opacity--active);
}
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="thumb"][data-carousel-state~="active"]
img {
transform: scale(1.1);
opacity: var(--ui-lightbox-thumb-opacity--active);
}
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="thumb"]:focus-visible {
outline: none;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="thumb"]:focus-visible
[data-lightbox-slot~="thumb-content"] {
--_ring-width: var(--ring-width);
outline-color: transparent;
}
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="thumb"]:focus-visible img {
transform: scale(1);
}
/* ---------------------------------------------------------------------------
DIALOG — [data-lightbox-slot~="dialog"] (fullscreen dark overlay)
--------------------------------------------------------------------------- */
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="dialog"] {
--ui-lightbox-thumb-rounded: var(--radius-sm);
--ui-dialog-bg: transparent;
--ui-dialog-border: none;
overflow: clip;
pointer-events: none;
outline: none;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="thumb"]
img {
border-radius: var(--ui-lightbox-thumb-rounded);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-dialog-slot~="content"] {
grid-template-rows: 1fr;
max-block-size: 100svh;
block-size: 100svh;
overflow: clip;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
:where(ui-carousel, [data-ui~="carousel"]) {
display: grid;
grid-template-rows: 1fr auto;
block-size: 100%;
max-block-size: 100svh;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-carousel-slot~="container"] {
block-size: 100%;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-carousel-slot~="viewport"] {
outline: none;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
:where(ui-carousel, [data-ui~="carousel"])
> [data-carousel-slot~="viewport"]:first-of-type {
min-block-size: 0;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="thumbs"] {
padding-block: var(--space-sm);
pointer-events: auto;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="thumbs"]
[data-carousel-slot~="container"] {
justify-content: center;
padding-block: var(--space-xs);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="thumb"] {
flex: 0 0 var(--ui-lightbox-thumb-size);
inline-size: var(--ui-lightbox-thumb-size);
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="thumb"]:focus-visible
[data-lightbox-slot~="thumb-content"] {
--_ring-fill: var(--color-foreground);
}
/* ---------------------------------------------------------------------------
SLIDE — [data-lightbox-slot~="slide"] (dialog full-size slides)
--------------------------------------------------------------------------- */
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="slide"] {
display: flex;
align-items: center;
justify-content: center;
min-block-size: 0;
min-inline-size: 0;
padding: clamp(var(--space-sm), 4vi, var(--space-lg));
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="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%;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="slide"]
img {
--ui-lightbox-thumb-rounded: var(--radius-xs);
border-radius: var(--ui-lightbox-slide-rounded);
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;
}
:where(ui-lightbox, [data-ui~="lightbox"])
[data-lightbox-slot~="dialog"]
[data-lightbox-slot~="slide"][data-carousel-state~="in-view"][data-carousel-state~="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-lightbox-slot~="prev"] / [data-lightbox-slot~="next"] (icon-size [data-ui~="button"])
--------------------------------------------------------------------------- */
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="prev"],
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="next"] {
--ui-button-ring-color: var(--color-foreground);
--ring-opacity: 80%;
pointer-events: auto;
position: absolute;
inset-block-start: 50%;
transform: translateY(-50%);
z-index: 50;
}
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="prev"] {
inset-inline-start: var(--space-md);
}
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="next"] {
inset-inline-end: var(--space-md);
}
/* ---------------------------------------------------------------------------
CLOSE BUTTON — top-right
--------------------------------------------------------------------------- */
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="close"] {
pointer-events: auto;
position: absolute;
inset-block-start: var(--space-sm);
inset-inline-end: var(--space-sm);
z-index: 10;
}
/* ---------------------------------------------------------------------------
COUNTER — slide count indicator (optional)
--------------------------------------------------------------------------- */
:where(ui-lightbox, [data-ui~="lightbox"]) [data-lightbox-slot~="counter"] {
position: absolute;
inset-block-end: var(--space-sm);
/* left + physical translate is a direction-neutral centering idiom; keep physical */
left: 50%;
translate: -50% 0;
opacity: 0.7;
font-size: var(--font-size-sm);
font-variant-numeric: tabular-nums;
pointer-events: none;
}
}// primitives/carousel/carousel.js
"use strict";
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).
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):
* - 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).
*
* @example
* <ui-lightbox>
* <div data-lightbox-slot="gallery">
* <ui-carousel data-lightbox-slot="stage" data-carousel-loop="true">…</ui-carousel>
* </div>
* <dialog data-ui="dialog" data-lightbox-slot="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 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-lightbox-slot~="gallery"] :is(ui-carousel, [data-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, [data-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-carousel-slot~="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, [data-ui~="carousel"])')?._emblaApi;
const galleryApi = this.#galleryRoot()?._emblaApi;
if (dialogApi && galleryApi) {
galleryApi.scrollTo(dialogApi.selectedScrollSnap());
}
}
}
defineZazzElement("ui-lightbox", UiLightbox);
export { UiLightbox };Behavior
primitives/lightbox/lightbox.js
<ui-lightbox> — HTML web component for lightbox galleries. 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):
- 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).
Events: zazz:dialog-close, zazz:dialog-open
Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="lightbox" | Identity | free value | Identity token in data-ui. |
<ui-lightbox> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-lightbox-slot | Slot | close content counter dialog gallery next prev slide stage thumb thumb-content thumbs thumbs-next thumbs-prev | Slot: a part inside the primitive. |
Requires: dialog, carousel, button.
Hooks
Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.
| Hook | Default | State |
|---|---|---|
--ui-lightbox-aspect | 3 / 2 | — |
--ui-lightbox-img-border | 1px solid var(--color-faded) | — |
--ui-lightbox-img-filter--active | brightness(0.5) | active |
--ui-lightbox-img-filter--hover | brightness(0.8) | hover |
--ui-lightbox-img-opacity | 1 | — |
--ui-lightbox-img-scale | 1 | — |
--ui-lightbox-img-scale--active | 1 | active |
--ui-lightbox-img-scale--hover | 1.03 | hover |
--ui-lightbox-img-transition-delay | 0ms | — |
--ui-lightbox-img-transition-duration | calc(var(--default-transition-duration) * 3) | — |
--ui-lightbox-img-transition-property | transform, filter | — |
--ui-lightbox-img-transition-timing-function | var(--default-transition-timing-function) | — |
--ui-lightbox-object-fit | cover | — |
--ui-lightbox-slide-opacity--end | 1 | end |
--ui-lightbox-slide-opacity--start | 0 | start |
--ui-lightbox-slide-rounded | var(--radius-lg) | — |
--ui-lightbox-slide-scale--end | 1 | end |
--ui-lightbox-slide-scale--start | 0.9 | start |
--ui-lightbox-thumb-columns | 6 | — |
--ui-lightbox-thumb-gap | calc(var(--spacing) * 1.5) | — |
--ui-lightbox-thumb-opacity | 0.5 | — |
--ui-lightbox-thumb-opacity--active | 1 | active |
--ui-lightbox-thumb-rounded | var(--radius-md) | — |
--ui-lightbox-thumb-size | calc(var(--spacing) * 16) | — |