Overlays

Lightbox

Thumbnails that open full-screen, with captions, a counter, the arrow keys, swipes and focus put back.

#overlay#media#image#interactive

Demo

Preview

1 / 6

Green hills under a blue sky

Day 1: the valley at noon

Click a photo, then use the arrow keys, the buttons or a swipe; Escape closes.

Get it

It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/lightbox.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/lightbox.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/lightbox.qwe
// A landscape drawn in SVG, so the demo needs no image files: sky, sun and hills in// the colours given, w by h.fn lightbox_demo_scene(sky: str, sun: str, near: str, far: str, w: int = 1200, h: int = 800) -> str {    let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">`        + `<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${sky}"/><stop offset="1" stop-color="#fff8ee"/></linearGradient></defs>`        + `<rect width="${w}" height="${h}" fill="url(#g)"/>`        + `<circle cx="${w * 0.7}" cy="${h * 0.32}" r="${h * 0.11}" fill="${sun}"/>`        + `<path d="M0 ${h * 0.62} Q ${w * 0.25} ${h * 0.42} ${w * 0.5} ${h * 0.6} T ${w} ${h * 0.55} V ${h} H 0 Z" fill="${far}"/>`        + `<path d="M0 ${h * 0.78} Q ${w * 0.3} ${h * 0.62} ${w * 0.6} ${h * 0.8} T ${w} ${h * 0.74} V ${h} H 0 Z" fill="${near}"/></svg>`    `data:image/svg+xml,${url_encode(svg)}`}let lightbox_demo_trip = [    { src: lightbox_demo_scene("#7cb7f0", "#ffd36b", "#2f6b3a", "#5c9a5f"), alt: "Green hills under a blue sky", caption: "Day 1: the valley at noon" },    { src: lightbox_demo_scene("#f3a46b", "#ff6b4a", "#5b3a5e", "#8a5a7a"), alt: "Purple hills at sunset", caption: "Day 1: sunset from the ridge" },    { src: lightbox_demo_scene("#1d2b53", "#f5f0d0", "#0f1a33", "#24345f"), alt: "Dark hills under a full moon", caption: "Day 2: the moon over camp" },    { src: lightbox_demo_scene("#bfe3f5", "#ffffff", "#e8eef3", "#c8d6e0"), alt: "Snowy hills on a pale morning", caption: "Day 3: snow overnight" },    { src: lightbox_demo_scene("#9fd8c8", "#ffe9a0", "#1f6f68", "#3f9c8f"), alt: "Teal hills by the coast", caption: "Day 4: down to the coast" },    { src: lightbox_demo_scene("#e7c6f5", "#ffb3c7", "#7a4b8c", "#a676b8"), alt: "Lilac hills at dawn", caption: "Day 5: the last morning" },]view LightboxDemo() =    <div.demo-stack>        <Lightbox label="Hiking trip" images={lightbox_demo_trip} size={120} ratio="4/3"/>        <p.demo-note>Click a photo, then use the arrow keys, the buttons or a swipe; Escape closes.</p>    </div>

Source

ui/lightbox.qwe
// Lightbox: a grid of thumbnails that opens each image over the whole screen, with// its caption and "3 / 12", the next and previous a button, an arrow key or a swipe// away. It is the browser's own <dialog>, so Escape closes it; focus then goes back// to the thumbnail of the image last seen.////   <Lightbox label="Kitchen renovation" images={[//       { src: "/photos/before.jpg", thumb: "/photos/before-sm.jpg", alt: "The old kitchen", caption: "Before: March" },//       { src: "/photos/after.jpg", thumb: "/photos/after-sm.jpg", alt: "The new kitchen", caption: "After: June" },//   ]}/>//// images: a list of { src, alt, thumb?, caption? }: src is the full image, thumb a// smaller one for the grid (src when there is none), alt what it shows (for screen// readers; also the name of its thumbnail), caption a line shown under it.// label: the gallery's name ("Images"). size: the thumbnails' width at least, in// pixels (140); they fill the row. ratio: their shape ("1", "4/3", "16/9"...).// loop (true): next from the last goes to the first.let lightbox_css = css`.ui-lb-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(var(--s, 140px), 100%), 1fr)); gap: 8px; margin: 0; padding: 0; list-style: none; }.ui-lb-thumb {  display: block; width: 100%; padding: 0; overflow: hidden; border: 0; border-radius: var(--ui-radius-sm);  background: var(--ui-subtle); cursor: zoom-in; aspect-ratio: var(--r, 1);}.ui-lb-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; transition: transform .25s; }.ui-lb-thumb:hover img { transform: scale(1.04); }.ui-lb-thumb:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ui-bg), 0 0 0 5px var(--ui-accent); }.ui-lb {  width: 100vw; height: 100dvh; max-width: none; max-height: none; margin: 0; padding: 0; border: 0;  background: color-mix(in srgb, var(--ui-bg) 94%, transparent); color: var(--ui-fg); font: 14px/1.4 var(--ui-font);}.ui-lb[open] { display: grid; grid-template-rows: auto 1fr auto; animation: ui-lb-in .18s ease-out; }.ui-lb::backdrop { background: var(--ui-overlay); backdrop-filter: blur(4px); }@keyframes ui-lb-in { from { opacity: 0; } }.ui-lb-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px 10px 18px; }.ui-lb-count { margin: 0; color: var(--ui-muted); font-variant-numeric: tabular-nums; }.ui-lb-stage { position: relative; display: grid; place-items: center; min-height: 0; padding: 0 64px; touch-action: pan-y; }.ui-lb-stage img {  display: block; max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--ui-radius-sm);  box-shadow: var(--ui-shadow-lg); user-select: none; -webkit-user-drag: none;}.ui-lb-cap { margin: 0; padding: 12px 18px 18px; text-align: center; color: var(--ui-fg); }.ui-lb-btn {  display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ui-border); border-radius: 50%;  background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow); cursor: pointer;}.ui-lb-btn:hover:not(:disabled) { background: var(--ui-subtle); }.ui-lb-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-lb-btn:disabled { opacity: .35; cursor: default; }.ui-lb-btn svg { width: 20px; height: 20px; }.ui-lb-prev, .ui-lb-next { position: absolute; top: 50%; transform: translateY(-50%); }.ui-lb-prev { left: 12px; }.ui-lb-next { right: 12px; }.ui-lb-x { width: 40px; height: 40px; box-shadow: none; }@media (max-width: 600px) {  .ui-lb-stage { padding: 0 8px; }  .ui-lb-prev, .ui-lb-next { top: auto; bottom: 8px; transform: none; }}@media (prefers-reduced-motion: reduce) { .ui-lb[open] { animation: none; } .ui-lb-thumb img { transition: none; } }`component Lightbox(images: list = [], label: str = "Images", size: int = 140, ratio: str = "1", loop: bool = true,                   class: str? = null) {    style global lightbox_css    state dlg = null    state at = 0    state sx = null    let n = len(images)    let cur = images[at] ?? { src: "", alt: "" }    let go = (i: int) => {        if n > 0 { at = loop ? (i + n) % n : max([0, min([i, n - 1])]) }    }    let show = (i: int) => {        at = i        dlg.showModal()    }    <div class={class}>        <ul.ui-lb-grid aria-label={label} style={`--s: ${size}px; --r: ${ratio}`}>            {for [i, im] in enumerate(images) => <li key={im.src}>                <button.ui-lb-thumb type="button" data-i={i} aria-haspopup="dialog" @click={() => { show(i) }}>                    <img src={get(im, "thumb") ?? im.src} alt={im.alt} loading="lazy"/>                </button>            </li>}        </ul>        <dialog.ui-lb aria-label={label} aria-roledescription="image viewer"                @mount={(el) => { dlg = el }}                @close={(e) => {                    let thumb = e.target.parentElement.querySelector(`.ui-lb-thumb[data-i="${at}"]`)                    thumb && thumb.focus()                }}                @keydown={(e) => {                    if e.key == "ArrowRight" { e.preventDefault(); go(at + 1) }                    if e.key == "ArrowLeft" { e.preventDefault(); go(at - 1) }                    if e.key == "Home" { e.preventDefault(); go(0) }                    if e.key == "End" { e.preventDefault(); go(n - 1) }                }}>            <div.ui-lb-top>                <p.ui-lb-count aria-live="polite" aria-atomic="true">{at + 1} / {n}</p>                <button.ui-lb-btn.ui-lb-x type="button" aria-label="Close" @click={() => dlg.close()}>                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                         aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>                </button>            </div>            <div.ui-lb-stage                 @click={(e) => { if e.target.classList.contains("ui-lb-stage") { dlg.close() } }}                 @pointerdown={(e) => { sx = e.clientX }}                 @pointerup={(e) => {                     if sx != null && abs(e.clientX - sx) > 50 { go(e.clientX < sx ? at + 1 : at - 1) }                     sx = null                 }}>                <img src={cur.src} alt={cur.alt} draggable="false"/>                <button.ui-lb-btn.ui-lb-prev type="button" aria-label="Previous image" disabled={!loop && at == 0}                        @click={() => { go(at - 1) }}>                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                         stroke-linejoin="round" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>                </button>                <button.ui-lb-btn.ui-lb-next type="button" aria-label="Next image" disabled={!loop && at == n - 1}                        @click={() => { go(at + 1) }}>                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"                         stroke-linejoin="round" aria-hidden="true"><path d="M9 5l7 7-7 7"/></svg>                </button>            </div>            <p.ui-lb-cap>{get(cur, "caption") ?? cur.alt}</p>        </dialog>    </div>}
Thread
56Useful? Like it, or add to its thread below.

Thread 0 posts

On Lightbox: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.