Theme
On this page (11)

Sub-pages: Image Gallery - an auto-advancing gallery composed from figure.image slides, with per-slide dwell times and every DOM write routed through defuss-query.

Component Skill — components/image/component-skill.md

Native basis

<figure> wrapping <img> with optional <figcaption>. Uses <dialog> for fullscreen lightbox.

Web Platform APIs

<figure><dialog>aspect-ratio@starting-style

Classes

.image.image-caption.image-fallback.image-lightbox

Data attributes

• data-preview - enables click-to-preview lightbox

• data-ratio - values: 1/1, 4/3, 3/2, 16/9, 21/9, 3/4

• data-fit - values: contain, fill, none

• data-radius - values: none, sm, md, full

• data-src-low - low-res placeholder, swapped for the real source once preloaded

• data-src-high - high-res source: used on ≥2dppx displays, or on the first lightbox zoom-in

Notes

• While the lightbox is modal, html:has(dialog.image-lightbox:modal) sets overflow: hidden + scrollbar-gutter: stable - the page behind cannot scroll and its position is preserved for when the lightbox closes (no JS scroll-lock).

§Default

Basic image in a figure with rounded corners and cover fit.

§With Caption

Semantic <figcaption> for descriptive text below the image.

§Fallback

Shows a placeholder when the image fails to load. Uses :has() to detect error state.

§Lightbox Preview

Click the image to open a fullscreen preview with zoom and rotate controls. Uses native <dialog>. data-src-full names a much larger original that only the lightbox loads: the preview opens instantly with the inline image, then swaps in the original once it has downloaded.

§Progressive Loading

data-src-low paints a blurred placeholder while the real image preloads in the background; data-src-high replaces it on ≥2dppx (retina) displays - and, combined with data-preview, on the first lightbox zoom-in.

§Aspect Ratios

Controlled aspect ratios via data-ratio.

§Object Fit

Control how the image fills its container via data-fit.

§Border Radius

Control border radius via data-radius.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - image loaded, caption/fallback hidden
  • error - .image-fallback revealed (the same state a failed network load produces)

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/image-{state}.png.

Machine contract - verified against image.schema.json by bun run verify:

StateTypeValuesDefaultDescription
errorbooleantrue, falsefalseBroken-image fallback styling (img[data-error]).

§CSS view file

Styles for figure, caption, fallback, and lightbox dialog.

@layer components {
  .image {
    position: relative;
    overflow: hidden;
    border-radius: var(--radius-lg);
    margin: 0;
    & > img {
      display: block;
      width: 100%;
      height: 100%;
      object-fit: cover;
      transition: filter 200ms ease;
    }
    /* -- Progressive placeholder --------------------------------
       Why: the low-res stand-in (data-src-low) shows blurred until the
       real source is preloaded and swapped in; 8px is a hardcoded filter
       value per the token boundary (no --* token for filters). */
    & > img[data-loading] {
      filter: blur(8px);
    }
    /* -- Fit variants ---------------------------------------- */
    &[data-fit="contain"] > img { object-fit: contain; }
    &[data-fit="fill"]    > img { object-fit: fill; }
    &[data-fit="none"]    > img { object-fit: none; }
    /* -- Aspect ratio ---------------------------------------- */
    &[data-ratio="1/1"]  { aspect-ratio: 1 / 1; }
    &[data-ratio="4/3"]  { aspect-ratio: 4 / 3; }
    &[data-ratio="3/2"]  { aspect-ratio: 3 / 2; }
    &[data-ratio="16/9"] { aspect-ratio: 16 / 9; }
    &[data-ratio="21/9"] { aspect-ratio: 21 / 9; }
    &[data-ratio="3/4"]  { aspect-ratio: 3 / 4; }
    /* -- Radius variants ------------------------------------- */
    &[data-radius="none"] { border-radius: 0; }
    &[data-radius="sm"]   { border-radius: var(--radius-sm); }
    &[data-radius="md"]   { border-radius: var(--radius-md); }
    &[data-radius="full"] { border-radius: 9999px; }
    /* -- Preview cursor -------------------------------------- */
    &[data-preview] {
      cursor: zoom-in;
    }
    /* -- Fallback -------------------------------------------- */
    & > .image-fallback {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      background-color: var(--muted);
      color: var(--muted-foreground);
      & > svg {
        width: 2.5rem;
        height: 2.5rem;
        opacity: 0.5;
      }
    }
    /* Hide fallback when image loads successfully */
    &:has(> img:not([data-error])) > .image-fallback {
      display: none;
    }
  }
  /* -- Caption ----------------------------------------------- */
  .image-caption {
    padding: 0.625rem 0 0.25rem;
    font-size: 0.8125rem;
    line-height: 1.4;
    color: var(--muted-foreground);
    text-wrap: pretty;
  }
  /* -- Lightbox dialog --------------------------------------- */
  dialog.image-lightbox {
    border: none;
    background: transparent;
    padding: 0;
    margin: auto;
    position: fixed;
    inset: 0;
    max-width: 100vw;
    max-height: 100vh;
    width: 100vw;
    height: 100vh;
    overflow: hidden;
    opacity: 0;
    transition: opacity 200ms ease, display 200ms allow-discrete;
    &[open] {
      opacity: 1;
    }
    &::backdrop {
      background: oklch(0 0 0 / 0);
      transition: all 200ms ease, display 200ms allow-discrete;
    }
    &[open]::backdrop {
      background: oklch(0 0 0 / 0.85);
    }
  }
  @starting-style {
    dialog.image-lightbox[open] {
      opacity: 0;
    }
    dialog.image-lightbox[open]::backdrop {
      background: oklch(0 0 0 / 0);
    }
  }
  /* -- Scroll lock ------------------------------------------- */
  /* Page behind stays put while the lightbox is modal (see dialog.css):
     `:modal` + overflow:hidden freezes the viewport at its current offset. */
  html:has(dialog.image-lightbox:modal) {
    overflow: hidden;
    scrollbar-gutter: stable;
  }
  .image-lightbox-content {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    position: relative;
    & > img {
      max-width: 90vw;
      max-height: 85vh;
      object-fit: contain;
      border-radius: var(--radius-md);
      transition: transform 200ms ease;
      user-select: none;
      -webkit-user-drag: none;
    }
  }
  .image-lightbox-toolbar {
    position: absolute;
    top: 1rem;
    right: 1rem;
    display: flex;
    gap: 0.25rem;
    z-index: 1;
  }
  .image-lightbox-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 2.25rem;
    height: 2.25rem;
    border: none;
    border-radius: var(--radius-md);
    background: oklch(0 0 0 / 0.5);
    color: white;
    cursor: pointer;
    transition: background 150ms ease;
    &:hover {
      background: oklch(0 0 0 / 0.7);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    & > svg {
      width: 1rem;
      height: 1rem;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .image > img,
    dialog.image-lightbox,
    dialog.image-lightbox::backdrop,
    .image-lightbox-content > img {
      transition: none;
    }
  }
  /* -- Hover gallery (daisyUI's hover-gallery, plus) -----------------------
     A .hover-gallery holds 2-10 images in one cell. The first shows; every
     other image is an invisible vertical strip of the frame (1/n of its
     width, in order) that the pointer can hit - hovering strip k shows
     image k full-size, and the strips of the others stay above it so moving
     on keeps working. CSS only. data-ratio sizes the frame (default 4/3),
     data-indicator draws a position bar, data-effect="zoom" eases the shown
     image in, data-direction="vertical" slices the frame into rows. On
     touch screens (no hover) it becomes a swipeable scroll-snap strip. */
  .hover-gallery {
    --_n: 1;
    --_cur: 1;
    position: relative;
    display: grid;
    overflow: hidden;
    aspect-ratio: 4 / 3;
    border-radius: var(--radius-lg);
    background-color: var(--muted);
    isolation: isolate;
    &[data-ratio="1/1"] { aspect-ratio: 1; }
    &[data-ratio="3/4"] { aspect-ratio: 3 / 4; }
    &[data-ratio="16/9"] { aspect-ratio: 16 / 9; }
    & > :is(img, picture, video) {
      grid-area: 1 / 1;
      display: block;
      width: 100%;
      height: 100%;
      min-width: 0;
      min-height: 0;
      object-fit: cover;
    }
    & > :not(:first-child) {
      z-index: 1;
      opacity: 0;
      /* strip k of n: the columns (k-1)/n .. k/n */
      clip-path: inset(0 calc(100% - var(--_k) * 100% / var(--_n)) 0 calc((var(--_k) - 1) * 100% / var(--_n)));
      /* the switch is instant: a crossfade would leave both images half
         transparent for a moment and the first would shine through */
    }
    & > :not(:first-child):hover {
      z-index: 0;
      opacity: 1;
      clip-path: none;
    }
    /* with image.js: no switching until every image is decoded (data-ready) -
       the first one stays instead of a half-loaded frame */
    &[data-init]:not([data-ready]) > :not(:first-child) { visibility: hidden; }
    &[data-direction="vertical"] > :not(:first-child) {
      clip-path: inset(calc((var(--_k) - 1) * 100% / var(--_n)) 0 calc(100% - var(--_k) * 100% / var(--_n)) 0);
    }
    &[data-direction="vertical"] > :not(:first-child):hover { clip-path: none; }
    /* strip 1 needs a hit area too: moving back from image k must leave it */
    &::before {
      content: '';
      position: absolute;
      z-index: 1;
      inset: 0 calc(100% - 100% / var(--_n)) 0 0;
    }
    &[data-direction="vertical"]::before { inset: 0 0 calc(100% - 100% / var(--_n)) 0; }
    /* k per image, n from the count, the shown one for the indicator */
    & > :nth-child(2) { --_k: 2; }
    & > :nth-child(3) { --_k: 3; }
    & > :nth-child(4) { --_k: 4; }
    & > :nth-child(5) { --_k: 5; }
    & > :nth-child(6) { --_k: 6; }
    & > :nth-child(7) { --_k: 7; }
    & > :nth-child(8) { --_k: 8; }
    & > :nth-child(9) { --_k: 9; }
    & > :nth-child(10) { --_k: 10; }
    &:has(> :nth-child(2)) { --_n: 2; }
    &:has(> :nth-child(3)) { --_n: 3; }
    &:has(> :nth-child(4)) { --_n: 4; }
    &:has(> :nth-child(5)) { --_n: 5; }
    &:has(> :nth-child(6)) { --_n: 6; }
    &:has(> :nth-child(7)) { --_n: 7; }
    &:has(> :nth-child(8)) { --_n: 8; }
    &:has(> :nth-child(9)) { --_n: 9; }
    &:has(> :nth-child(10)) { --_n: 10; }
    &:has(> :nth-child(2):hover) { --_cur: 2; }
    &:has(> :nth-child(3):hover) { --_cur: 3; }
    &:has(> :nth-child(4):hover) { --_cur: 4; }
    &:has(> :nth-child(5):hover) { --_cur: 5; }
    &:has(> :nth-child(6):hover) { --_cur: 6; }
    &:has(> :nth-child(7):hover) { --_cur: 7; }
    &:has(> :nth-child(8):hover) { --_cur: 8; }
    &:has(> :nth-child(9):hover) { --_cur: 9; }
    &:has(> :nth-child(10):hover) { --_cur: 10; }
    /* position bar: n segments, the shown one bright */
    &[data-indicator]::after {
      content: '';
      position: absolute;
      z-index: 2;
      inset: auto 0.75rem 0.625rem;
      height: 3px;
      pointer-events: none;
      --_seg: calc((100% + 4px) / var(--_n));
      /* stops along the full width: the shown segment bright, all segments dim */
      background:
        linear-gradient(90deg, transparent calc((var(--_cur) - 1) * var(--_seg)), oklch(1 0 0) 0 calc(var(--_cur) * var(--_seg) - 4px), transparent 0),
        repeating-linear-gradient(90deg, oklch(1 0 0 / 0.45) 0 calc(var(--_seg) - 4px), transparent 0 var(--_seg));
      border-radius: 9999px;
      filter: drop-shadow(0 1px 1px oklch(0 0 0 / 0.35));
      opacity: 0;
      transition: opacity 150ms ease;
    }
    &[data-indicator]:hover::after,
    &[data-indicator="always"]::after { opacity: 1; }
    /* zoom: the shown image eases in from a slight scale */
    &[data-effect="zoom"] > :is(img, picture, video) { transition: scale 400ms ease; }
    &[data-effect="zoom"]:hover > :first-child:not(:has(~ :hover)),
    &[data-effect="zoom"] > :not(:first-child):hover { scale: 1.04; }
  }
  /* no hover (touch): a swipeable strip, one image per view */
  @media (hover: none) {
    .hover-gallery {
      display: flex;
      overflow-x: auto;
      scroll-snap-type: x mandatory;
      overscroll-behavior-inline: contain;
      scrollbar-width: none;
      & > :is(img, picture, video),
      & > :not(:first-child) {
        flex: 0 0 100%;
        opacity: 1;
        clip-path: none;
        scroll-snap-align: start;
      }
      &[data-indicator]::after { display: none; }
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .hover-gallery > * { transition: none; }
    .hover-gallery[data-effect="zoom"] > * { scale: none !important; }
  }
}

§JavaScript view file

Fallback error detection and lightbox preview with zoom/rotate.

/* -- Image component ----------------------------------------- */
/* Fallback on error, progressive sources (data-src-low /        */
/* data-src-high) + lightbox preview for [data-preview].         */
/* Named-state API bound per figure so agents/tests can show     */
/* the fallback without a network failure (AGENTS.md "State       */
/* API").                                                        */
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
// defussQuery: the callable runtime - error flag + lightbox control writes
// ride query scalars, the sheet mounts through query .append(); the lightbox
// template is a trusted static markup string (§3 image row, §5.1: lightbox
// content re-renders are the real morph path when added - the toolbar is a
// static singleton, so it stays a one-shot render).
import { defussGlobals, defussQuery } from '../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const imageStates = ['default', 'error'];
/**
 * UI side of setState (per figure): 'error' marks the img like a failed load
 * would (CSS then reveals .image-fallback); 'default' clears the mark.
 */
function triggerStateChange(figure, stateName, _config) {
  const img = dfDollar(figure).find('img')[0];
  if (!img) return;
  // data-error is the CSS fallback marker - set/removed via query data scalars
  switch (stateName) {
    case 'default':
      dfDollar(img).data('error', null);
      break;
    case 'error':
      dfDollar(img).data('error', '');
      break;
  }
}
/** Registry-level API; pass the figure explicitly. Unknown names throw. */
export const imageApi = {
  setState(figure, stateName, config = {}) {
    if (!imageStates.includes(stateName)) {
      throw new Error(`image: unknown state "${stateName}" (supported: ${imageStates.join(', ')})`);
    }
    triggerStateChange(figure, stateName, config);
    // state lives on the ELEMENT, not the module (many images per page)
    figure.dataset.stateName = stateName;
    figure._stateConfig = config;
  },
  getState(figure) {
    // reflect reality: load/error events flip it without setState()
    const img = dfDollar(figure).find('img')[0];
    return {
      name: img && dfDollar(img).data('error') !== undefined ? 'error' : 'default',
      config: figure._stateConfig ?? {},
    };
  },
};
df$.imageApi = imageApi;
df$.imageStates = imageStates;
/* -- Hover gallery: preload + decode before switching -------------------
   A hover gallery shows its images instantly as the pointer crosses the
   strips - an image that is still downloading or not yet decoded would
   flash the first one through. So every image loads eagerly, and once the
   gallery comes near the viewport all of them are decoded (img.decode());
   only then does it switch (data-ready - until then the first stays). */
const galleryIO = typeof IntersectionObserver === 'function'
  ? new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (!e.isIntersecting) continue;
        galleryIO.unobserve(e.target);
        readyGallery(e.target);
      }
    }, { rootMargin: '300px' })
  : null;
function readyGallery(gallery) {
  const imgs = [...gallery.querySelectorAll(':scope > img, :scope > picture img')];
  Promise.all(imgs.map((img) => (img.decode ? img.decode() : Promise.resolve()).catch(() => undefined))).then(() => {
    gallery.dataset.ready = '';
  });
}
function initHoverGalleries() {
  document.querySelectorAll('.hover-gallery:not([data-init])').forEach((gallery) => {
    gallery.dataset.init = '';
    gallery.querySelectorAll(':scope > img, :scope > picture img').forEach((img, i) => {
      if (img.loading === 'lazy') img.loading = 'eager';
      if (i > 0 && !img.hasAttribute('fetchpriority')) img.fetchPriority = 'low';
    });
    if (galleryIO) galleryIO.observe(gallery);
    else readyGallery(gallery);
  });
}
function init() {
initHoverGalleries();
/* -- Fallback: mark images that fail to load ----------------- */
document.querySelectorAll('.image:not([data-init])').forEach((figure) => {
  figure.dataset.init = '';
  // bind-scope the api per figure: `$('#hero').api.setState('error')`
  figure.api = {
    setState: (stateName, config) => imageApi.setState(figure, stateName, config),
    getState: () => imageApi.getState(figure),
  };
  const img = dfDollar(figure).find('img')[0];
  if (!img) return;
  if (img.complete && img.naturalWidth === 0) {
    dfDollar(img).data('error', '');
  }
  img.addEventListener('error', () => {
    dfDollar(img).data('error', '');
    figure.dataset.stateName = 'error'; // State API marker stays dataset.*
  });
  img.addEventListener('load', () => {
    dfDollar(img).data('error', null);
    figure.dataset.stateName = 'default';
  });
  /* -- Progressive sources (data-src-low / data-src-high) -------
     Why: data-src-low paints a cheap placeholder while the real
     source preloads off-DOM (new Image()); data-src-high is a
     retina-only upgrade - paying its bytes on 1dppx displays (or
     before a lightbox zoom-in) would waste bandwidth. */
  const srcLow = img.dataset.srcLow;
  const srcHigh = img.dataset.srcHigh;
  if (srcLow || srcHigh) {
    const retina = !!srcHigh && globalThis.matchMedia('(min-resolution: 2dppx)').matches;
    const finalSrc = retina ? srcHigh : img.getAttribute('src');
    if (retina) img.dataset.srcHighLoaded = ''; // first zoom-in must not re-fetch
    if (srcLow && finalSrc) {
      dfDollar(img).data('loading', ''); // CSS blurs the placeholder
      img.src = srcLow;
      const preload = new Image();
      // either way the img gets the real source - its own load/error
      // listeners above then settle the State API marker
      preload.onload = preload.onerror = () => {
        img.src = finalSrc;
        dfDollar(img).data('loading', null);
      };
      preload.src = finalSrc;
    } else if (retina) {
      img.src = srcHigh;
    }
  }
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });
/* -- Lightbox ------------------------------------------------ */
let lightbox = null;
let lightboxImg = null;
let lightboxFigure = null; // figure the lightbox is showing (high-res upgrade target)
let zoom = 1;
let rotation = 0;
function getLightbox() {
  if (lightbox) return lightbox;
  lightbox = document.createElement('dialog');
  lightbox.className = 'image-lightbox';
  lightbox.setAttribute('aria-label', 'Image preview');
  // trusted static toolbar markup through query's .html() (§5.1: sanctioned
  // render path - a static singleton template, no user content)
  dfDollar(lightbox).html(`
    <div class="image-lightbox-content">
      <img src="" alt="" />
    </div>
    <div class="image-lightbox-toolbar">
      <button class="image-lightbox-btn" data-action="zoom-in" aria-label="Zoom in">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
      </button>
      <button class="image-lightbox-btn" data-action="zoom-out" aria-label="Zoom out">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
      </button>
      <button class="image-lightbox-btn" data-action="rotate-left" aria-label="Rotate left">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 2v6h6"/><path d="M2.66 15.57a10 10 0 1 0 .57-8.38"/></svg>
      </button>
      <button class="image-lightbox-btn" data-action="rotate-right" aria-label="Rotate right">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6"/><path d="M21.34 15.57a10 10 0 1 1-.57-8.38"/></svg>
      </button>
      <button class="image-lightbox-btn" data-action="reset" aria-label="Reset">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
      </button>
      <button class="image-lightbox-btn" data-action="close" aria-label="Close">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
      </button>
    </div>`);
  lightboxImg = dfDollar(lightbox).find('.image-lightbox-content > img')[0];
  /* Toolbar actions */
  dfDollar(lightbox).find('.image-lightbox-toolbar')[0].addEventListener('click', (e) => {
    const btn = e.target.closest('[data-action]');
    if (!btn) return;
    const action = btn.dataset.action;
    if (action === 'zoom-in') {
      zoom = Math.min(zoom + 0.25, 5);
      upgradeLightboxToHigh(); // the first zoom earns the high-res bytes
    }
    else if (action === 'zoom-out') zoom = Math.max(zoom - 0.25, 0.25);
    else if (action === 'rotate-left')  rotation -= 90;
    else if (action === 'rotate-right') rotation += 90;
    else if (action === 'reset') { zoom = 1; rotation = 0; }
    else if (action === 'close') { lightbox.close(); return; }
    applyTransform();
  });
  /* Close on backdrop click */
  lightbox.addEventListener('click', (e) => {
    if (e.target === lightbox) lightbox.close();
  });
  dfDollar(document.body).append(lightbox); // query's exact mount op
  return lightbox;
}
/* Why: the high-res source earns its bytes only once the user actually
   zooms - the first zoom-in swaps lightbox AND figure img, never sooner. */
function upgradeLightboxToHigh() {
  if (!lightboxFigure) return;
  const img = dfDollar(lightboxFigure).find('img')[0];
  if (img && img.dataset.srcFull) return; // the lightbox already shows the original
  const srcHigh = img && img.dataset.srcHigh;
  if (!srcHigh || img.dataset.srcHighLoaded !== undefined) return;
  img.dataset.srcHighLoaded = '';
  img.src = srcHigh;
  if (lightboxImg) lightboxImg.src = srcHigh;
}
function applyTransform() {
  if (lightboxImg) {
    dfDollar(lightboxImg).css('transform', `scale(${zoom}) rotate(${rotation}deg)`);
  }
}
function openLightbox(figure) {
  const lb = getLightbox();
  zoom = 1;
  rotation = 0;
  const img = dfDollar(figure).find('img')[0];
  lightboxFigure = figure;
  const $img = dfDollar(lightboxImg);
  // consumer-provided src flows in via <img> attributes already (§5.2: the
  // figure's own src/alt are the trusted source, attr writes mirror them)
  $img.attr('src', img.src).attr('alt', img.alt || '').css('transform', null).css('width', null);
  /* data-src-full: the lightbox-only original (much larger than the inline
     src). Why: the page should never pay for it - only an actual preview
     does. The cached inline src opens instantly, pre-sized to the fitted
     frame the original will fill; the original preloads off-DOM and swaps
     in without a layout jump (the width lock is released on swap). */
  const srcFull = img.dataset.srcFull;
  if (srcFull) {
    const ratio = img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 0;
    if (ratio) $img.css('width', `min(90vw, calc(85vh * ${ratio.toFixed(4)}))`);
    const full = new Image();
    full.onload = () => {
      if (lightboxFigure !== figure || !lb.open) return; // closed / another image meanwhile
      $img.attr('src', srcFull).css('width', null);
    };
    full.src = srcFull;
  }
  lb.showModal(); // native dialog protocol stays native
}
/* -- Attach preview click handlers --------------------------- */
if (!document.__imagePreviewInit) {
  document.__imagePreviewInit = true;
  document.addEventListener('click', (e) => {
    const figure = e.target.closest('.image[data-preview]');
    if (!figure) return;
    const img = dfDollar(figure).find('img')[0];
    if (!img || dfDollar(img).data('error') !== undefined) return;
    openLightbox(figure);
  });
}

Comments, ideas or improvements? Edit this page's source on GitHub