Image GalleryATM
Compositions on top of the Image component: a hover gallery that shows the next photo as the pointer crosses it (CSS only), and a crossfading, auto-advancing gallery where each slide declares its own dwell time in markup (data-duration) and a small defuss-query controller owns selection, state attributes, and the timer - no raw querySelector/classList anywhere.
On this page (10)
§Auto-advancing gallery
Five figure.image slides crossfade on a re-armed setTimeout chain: each slide's data-duration is its dwell (the last slide has none and falls back to the Default dwell field). Prev/next reschedule the timer, the toggle pauses it - and every DOM read/write in the controller goes through df$ (defuss-query).
The pattern: df$ (defuss-query) drives every DOM touch - selection (find / children / eq), attribute reads and writes (data / attr), label text (text), control values (val) and events (on) - while the crossfade itself is pure CSS keyed on the data-active attribute. Auto-advance is one re-armed setTimeout instead of a fixed interval: each slide's dwell is its own data-duration, falling back to the "Default dwell" field, and every manual navigation, pause, or config edit clears and re-arms the timer - user control always wins over the schedule.
§Hover gallery
daisyUI's hover gallery, CSS only: a .hover-gallery holds 2 - 10 images in one frame. The first shows; moving the pointer across the frame shows the others - the frame is sliced into as many invisible strips as there are images. Typical for product cards: glance through the photos without clicking. On touch screens (no hover) the same markup becomes a swipeable strip.
§Hover gallery
Move the pointer from left to right across the picture - each fourth of the frame shows the next photo; back at the left edge the first returns.
§Product card
Inside a link card: the gallery previews the product, the whole card stays one link. data-indicator draws the position bar on hover.
§Position bar
data-indicator shows n segments on hover (the bright one is the photo in view); data-indicator='always' keeps them visible - a hint that there is more to see.
§Zoom
data-effect='zoom' eases the photo in view slightly larger - a livelier preview. Reduced motion turns it off.
§Vertical zones
data-direction='vertical' slices the frame into rows instead of columns - for tall portraits, move the pointer top to bottom.
§Up to ten images
Ten photos, ten strips - the frame adapts to the count by itself (:has() counts the children).
§CSS view file
@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
/* -- 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