diklein
componentdk-media-viewersource: ok · hit
DKMediaViewer
A minimalist, performant, and elegant photo and video viewer for React. Masonry grid, fly-in lightbox, a crossfading carousel, captions and camera EXIF data, light and dark modes, reduced-motion support, keyboard navigation, and one easter egg.
npx shadcn add "https://diklein.com/r/dk-media-viewer.json"free
Where it goes
kanbangallery
Visual style
dark-futuristicminimal
Interaction
carousel
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Files5 · source cached 10/8/2026, 9:42:44 PM
registry/dk-media-viewer/dk-media-viewer.tsx
'use client'
/* DKMediaViewer — the masonry grid + the lightbox it opens.
*
* <DKMediaViewer items={items} />
*
* Items are plain data (see types.ts) — hand-written, CMS-mapped, or generated with
* `npx @diklein/dkmediaviewer scan ./public/photos`. Photos render as lazy <img>s (or through your own
* renderImage slot, e.g. next/image); videos autoplay muted in the grid and hand off
* frame-accurately to the lightbox player when clicked. */
import { useState, useEffect, useRef } from 'react'
import type { DKMediaItem } from './types'
import { useDKLightbox } from './dk-lightbox'
export interface DKMediaViewerProps {
items: DKMediaItem[]
/** Extra classes on the outer grid wrapper. */
className?: string
/** A sharper large variant for the lightbox to fade in over the base image (and to prefetch
* on hover and around the open item) — e.g. a CDN resize URL. Default: none; the lightbox
* shows item.src, which for local files is already the full image. */
getHiResSrc?: (item: DKMediaItem) => string
/** Render the grid image yourself (e.g. with next/image). Apply ctx.className to the visible
* image element and honor ctx.sizes/ctx.priority; the lightbox still works untouched — it
* reads the rendered <img>'s decoded file at click time. */
renderImage?: (
item: DKMediaItem,
ctx: { index: number; priority: boolean; sizes: string; className: string },
) => React.ReactNode
/** The 1px photo edge outline in the lightbox. Default true (photographs). */
showOutline?: boolean
/** Reserve the lightbox caption/exif rail. Default: on when any item carries one. */
hasCaptions?: boolean
/** Open from `?photo=<index>` on mount, for shareable deep links. Default off. */
deepLink?: boolean
}
const GRID_SIZES = '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
const IMG_CLASS = 'dk-img-outline w-full h-auto transition-opacity duration-200 group-hover:opacity-85'
export function DKMediaViewer({
items,
className,
getHiResSrc,
renderImage,
showOutline = true,
hasCaptions,
deepLink = false,
}: DKMediaViewerProps) {
const [numCols, setNumCols] = useState(3)
// Reduced-motion visitors get still thumbnails: autoPlay drops and each clip holds its poster
// frame, with the lightbox as the play path (its player carries explicit controls). Read in an
// effect so the SSR markup stays stable; live-updated because the OS toggle can flip mid-visit.
const [reducedMotion, setReducedMotion] = useState(false)
const prefetched = useRef<Set<string>>(new Set())
// Thumbnail registry: close hands focus back to the item the viewer ended on, and deep links
// need a zoom origin — both resolved through here.
const thumbEls = useRef<Map<number, HTMLButtonElement>>(new Map())
const { open, lightbox } = useDKLightbox(items, {
getHiResSrc,
showOutline,
hasCaptions,
deepLink,
getOriginEl: (i) => thumbEls.current.get(i) ?? null,
})
// Warm the hi-res layer on hover/focus so it is already downloading by the time the lightbox
// opens — the browser cache hit makes the sharp layer's fade-in instant instead of a re-fetch
// after the fly-in lands. No-op without getHiResSrc (the lightbox then reuses the grid's file).
const prefetchHiRes = (item: DKMediaItem) => {
if (!getHiResSrc) return
const hi = getHiResSrc(item)
if (!hi || hi === item.src || prefetched.current.has(hi)) return
prefetched.current.add(hi)
const img = new window.Image()
img.src = hi
}
useEffect(() => {
const sm = window.matchMedia('(max-width: 639px)')
const md = window.matchMedia('(max-width: 1023px)')
const update = () => {
if (sm.matches) setNumCols(1)
else if (md.matches) setNumCols(2)
else setNumCols(3)
}
update()
sm.addEventListener('change', update)
md.addEventListener('change', update)
return () => {
sm.removeEventListener('change', update)
md.removeEventListener('change', update)
}
}, [])
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = () => setReducedMotion(mq.matches)
update()
mq.addEventListener('change', update)
return () => mq.removeEventListener('change', update)
}, [])
// Mounted clips don't react to the autoPlay attribute changing, so a live preference flip is
// applied by hand: pause everything when motion is reduced, resume when the preference lifts.
useEffect(() => {
thumbEls.current.forEach((btn) => {
const video = btn.querySelector('video')
if (!video) return
if (reducedMotion) video.pause()
else video.play().catch(() => {})
})
}, [reducedMotion])
if (items.length === 0) return null
const columns: Array<Array<{ item: DKMediaItem; originalIndex: number }>> =
Array.from({ length: numCols }, () => [])
items.forEach((item, i) => columns[i % numCols].push({ item, originalIndex: i }))
return (
<>
{/* data-dk-scope: the lightbox pauses playing <video>s inside this wrapper while open. */}
<div data-dk-scope className={`dk-scope flex gap-2${className ? ` ${className}` : ''}`}>
{columns.map((colItems, colIndex) => (
<div key={colIndex} className="flex-1 flex flex-col gap-2">
{colItems.map(({ item, originalIndex }) => (
<button
key={item.src}
ref={(el) => {
if (el) thumbEls.current.set(originalIndex, el)
else thumbEls.current.delete(originalIndex)
}}
onMouseEnter={() => prefetchHiRes(item)}
onFocus={() => prefetchHiRes(item)}
onClick={(e) => open(originalIndex, e.currentTarget, e)}
// No focus-visible override: the host's global :focus-visible styling (or the
// browser default) gives keyboard users their ring — opting out leaves them
// tabbing blind.
className="dk-cv-auto block w-full cursor-zoom-in group"
aria-label={item.alt ?? item.caption ?? (item.videoSrc ? 'Play video' : 'Open photo')}
>
{item.videoSrc ? (
// Clips autoplay muted in the grid (poster = item.src covers the load) — except
// under prefers-reduced-motion, where they sit on their poster and play in the
// lightbox instead. The lightbox resumes from this element's exact frame — see
// useDKLightbox's capture.
<video
src={item.videoSrc}
poster={item.src}
muted
loop
playsInline
autoPlay={!reducedMotion}
preload="metadata"
className={IMG_CLASS}
style={
item.width && item.height
? { aspectRatio: `${item.width} / ${item.height}` }
: undefined
}
/>
) : renderImage ? (
renderImage(item, {
index: originalIndex,
priority: originalIndex < 3,
sizes: GRID_SIZES,
className: IMG_CLASS,
})
) : (
<img
src={item.src}
alt={item.alt ?? item.caption ?? ''}
width={item.width}
height={item.height}
sizes={GRID_SIZES}
className={IMG_CLASS}
loading={originalIndex < 3 ? 'eager' : 'lazy'}
decoding="async"
// Blur-up stand-in: the item's dominant color fills the box until the image
// paints, then clears (so transparent images don't keep a colored tile).
style={item.color ? { backgroundColor: item.color } : undefined}
onLoad={(e) => { e.currentTarget.style.backgroundColor = '' }}
/>
)}
</button>
))}
</div>
))}
</div>
{lightbox}
</>
)
}
registry/dk-media-viewer/dk-lightbox.tsx
'use client'
/* DKLightbox — the fly-in modal viewer, plus useDKLightbox(), the controller hook the grid
* (dk-media-viewer) and the carousel (dk-carousel) both drive it through.
*
* Extracted from diklein.com, where every timing decision below was measured against a real
* Safari/Chrome flash or jitter before it earned its comment. The war stories are kept: they
* are the reason the code is shaped the way it is, and deleting them invites regressing it. */
import { useState, useEffect, useRef, useLayoutEffect, useCallback } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import { flushSync } from 'react-dom'
import { createPortal } from 'react-dom'
import * as m from 'motion/react-m'
import {
LazyMotion,
domAnimation,
useMotionValue,
useTransform,
animate,
useReducedMotion,
} from 'motion/react'
import type { DKMediaItem } from './types'
import { formatExif } from './types'
/* ------------------------------------------------------------------------------------------------
* The lightbox's working item: the public DKMediaItem plus runtime capture the opener takes at
* click time. None of this is hand-written by users — useDKLightbox fills it in.
* ---------------------------------------------------------------------------------------------- */
export interface DKLightboxItem extends DKMediaItem {
/** The origin <img>'s decoded file (its currentSrc) — the fly-in clone and the slide's base
* layer paint this straight from cache, so the open never waits on a re-fetch. */
flightSrc?: string
/** A canvas snapshot of the exact video frame that was clicked — the clone's guaranteed paint
* while its own <video> is still seeking. */
flightPoster?: string
/** The clip's playhead at click; the clone and the modal player both resume from this frame. */
videoTime?: number
/** Mirror a progress bar under the clip in the modal. */
videoProgress?: boolean
}
// Should the clicked clip keep PLAYING while it flies into the modal?
//
// TRUE (the shipped behaviour): the flying clone is a second live <video> that resumes from the
// origin's exact frame, so the clip never stops moving as it travels into the modal.
//
// FALSE: the clone is the FROZEN frame instead (flightPoster, a canvas snapshot of the clicked
// frame). Identical pixels, no video element inside the animating layer; the clip just does not
// advance during the flight. The handoff is frame-accurate either way, because the modal clip
// starts from item.videoTime — the same frame the clone starts from — and both play at 1x, so
// they stay together without anything having to reconcile them (see LightboxVideo).
const FLY_LIVE_VIDEO = true
/* THE HOUSE SPRING. ζ ≈ 0.86, ωₙ ≈ 30: about half a percent of overshoot, settled in ~150ms —
* present, not gratuitous. Overshoot is a percentage of the distance travelled, so one spring
* serves an 8px settle and an 800px flight alike; the drag settle, the fly-in open and the
* left/right nav all share it deliberately, so the same gesture always feels like the same
* object. */
const SPRING = { type: 'spring', stiffness: 620, damping: 36, mass: 0.7 } as const
/* The fly-in's PER-UNIT rest thresholds. motion completes a spring inside restDelta
* and SNAPS to the end value; the default (0.01) suits pixel tracks, but on the
* flight's SCALE tracks 1% of a large photo is a visible teleport at the far edge
* (transform-origin 0 0 puts it all on the right/bottom). Sub-pixel thresholds render
* the overshoot's whole tail and let completion fire at true visual rest, so the
* clone handoff cuts between still frames. Mirrors photos-lightbox (2026-08-26). */
const OPEN_SPRING_SCALE = { ...SPRING, restDelta: 0.0005, restSpeed: 0.005 } as const
const OPEN_SPRING_PX = { ...SPRING, restDelta: 0.1, restSpeed: 1 } as const
const OPEN_SPRING = { x: OPEN_SPRING_PX, y: OPEN_SPRING_PX, scaleX: OPEN_SPRING_SCALE, scaleY: OPEN_SPRING_SCALE } as const // the fly-in from the thumbnail
const NAV_SPRING = SPRING // left / right between assets
/* prefers-reduced-motion: the MOVE is kept — it carries spatial meaning — but the decorative
* overshoot is removed. Critically damped: it arrives and stops. */
const NAV_SPRING_REDUCED = { type: 'spring', stiffness: 300, damping: 40, mass: 1 } as const
/* Shift-click / shift-arrow: the same character stretched to ~3s, so every phase of the
* entrance can be inspected frame by frame. (Mac OS X 10.3 shipped shift-click-minimize as a
* slow-motion genie effect. Some of us never got over it.) */
const SPRING_SLOW = { type: 'spring', duration: 3, bounce: 0.2 } as const
const SLOW_OPEN_SPRING = SPRING_SLOW
const SLOW_NAV_SPRING = SPRING_SLOW
const CLOSE_DRAG = 100 // swipe the image down past this (px) to dismiss
/* The veil (the in-document layer the iOS bottom bar blurs — see the backdrop effect) never
* goes below THIS opacity while mounted: at exactly 0 an element paints nothing and Safari
* tears its layers down, and rebuilding is the lazy path that popped instead of fading. */
const VEIL_MIN_OPACITY = 0.002
// Contain-fit a photo of intrinsic (w×h) inside a container rect → the image's actual on-screen
// rectangle. The flying-clone open flies to THIS rect (not the container's), so start and end
// share the photo's aspect ratio and the clone scales uniformly — a pure GPU transform, no
// distortion (which independent width/height or scaleX≠scaleY would cause).
function containRect(
c: { left: number; top: number; width: number; height: number },
w: number,
h: number,
) {
if (!w || !h) return { left: c.left, top: c.top, width: c.width, height: c.height }
const ar = w / h
let iw = c.width
let ih = c.width / ar
if (ih > c.height) { ih = c.height; iw = c.height * ar }
return { left: c.left + (c.width - iw) / 2, top: c.top + (c.height - ih) / 2, width: iw, height: ih }
}
function IconClose() {
return <X aria-hidden="true" size={24} />
}
function IconArrow({ dir }: { dir: 'left' | 'right' }) {
return dir === 'left'
? <ChevronLeft aria-hidden="true" size={28} />
: <ChevronRight aria-hidden="true" size={28} />
}
export interface DKLightboxProps {
item: DKLightboxItem
index: number
total: number
onClose: () => void
onPrev: () => void
onNext: () => void
originRect: DOMRect | null
prevItem: DKLightboxItem | null
nextItem: DKLightboxItem | null
/** Reserve space for a caption/exif line under the image. When no item in the gallery carries
* either, turn this off — the image then gets the freed vertical space. */
hasCaptions?: boolean
/** The 1px edge outline on the photo (matches the grid's .dk-img-outline treatment and keeps
* near-white / near-black photographs from merging into the scrim). Turn off for assets that
* are not photographs — on screenshots and diagrams it reads as a stray border flashing in
* and out as the overlay opens. */
showOutline?: boolean
/** Shift-click open: stretch the whole entrance to ~3s for frame-by-frame inspection. */
slowMo?: boolean
/** A sharper large variant to fade in over the base image (e.g. a CDN resize URL). Default:
* none — the lightbox shows item.src, which is already the full file for local photos. */
getHiResSrc?: (item: DKMediaItem) => string
/** Fired ONCE, when the fly-in clone paints its first real frame (image decoded / first
* video frame). The opener hides the clicked origin element on this signal rather than at
* click, so the asset never blinks out before its flying copy is visibly on screen. */
onFlightPainted?: () => void
/** Fired ONCE, when the open has fully settled (the flying clone has been dropped and the
* modal owns the screen). The opener RESTORES the hidden origin element on this signal —
* the modal covers it completely, and having it back means a later swipe-down reveals the
* asset you tapped instead of a blank hole in the page. */
onSettled?: () => void
}
/** Loading shimmer sized to the asset's contained (letterboxed) rect rather than the whole
* slide — so while a wide clip or tall photo loads, the placeholder matches the shape that
* will appear, not the entire modal. Falls back to filling the slide if the aspect is
* unknown (dimensions weren't supplied). */
// Measures a photo's object-contain rect (w x h fitted inside the host box), tracking resize.
// Shared by the loading shimmer and the persistent edge outline so both hug the photo exactly.
function useContainRect(w?: number, h?: number) {
const ref = useRef<HTMLDivElement>(null)
const [box, setBox] = useState<{ w: number; h: number } | null>(null)
useLayoutEffect(() => {
const host = ref.current
if (!host || !w || !h) { setBox(null); return }
const measure = () => {
const cw = host.clientWidth, ch = host.clientHeight
if (!cw || !ch) return
const a = w / h
let dw = cw, dh = cw / a
if (dh > ch) { dh = ch; dw = ch * a }
setBox({ w: Math.round(dw), h: Math.round(dh) })
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(host)
return () => ro.disconnect()
}, [w, h])
return { ref, box }
}
function AssetShimmer({ w, h }: { w?: number; h?: number }) {
const { ref, box } = useContainRect(w, h)
return (
<div ref={ref} aria-hidden className="absolute inset-0 z-0 flex items-center justify-center">
<div
className="dk-shimmer relative"
style={box ? { width: box.w, height: box.h } : { position: 'absolute', inset: 0 }}
/>
</div>
)
}
// Persistent neutral edge outline hugging the photo's contained rect — the same 1px treatment as
// the grid (.dk-img-outline), so near-white / near-black photos don't merge with the scrim.
// Sits above both image layers (z-30) so its inward outline paints over the photo's edge; photos
// only, as videos keep their own progress-bar treatment.
function AssetOutline({ w, h }: { w?: number; h?: number }) {
const { ref, box } = useContainRect(w, h)
return (
<div ref={ref} aria-hidden className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center">
{box && <div className="dk-img-outline" style={{ width: box.w, height: box.h }} />}
</div>
)
}
/** A clip playing inside the lightbox: shimmer while it loads, then a 2px accent progress bar
* matched to the video's contained (letterboxed) rect. Only the current slide autoplays; on
* nav the slide remounts (keyed by src) so it reliably plays. */
function LightboxVideo({ item, priority, onLoad, armed = true, getCloneTime }: {
item: DKLightboxItem
priority?: boolean
onLoad?: () => void
armed?: boolean
/** The playhead of the frame the flying clone is CURRENTLY showing — its live video's time
* when that has painted, else the frozen poster's captured time. The reveal syncs to this,
* because this clip plays behind the veil while it loads and drifts away from the clone. */
getCloneTime?: () => number | null
}) {
// Reduced motion: no autoplay; the native controls become the play affordance
// (ported from photos-lightbox, 2026-08-28 drift audit).
const reduceMotion = useReducedMotion()
const ref = useRef<HTMLVideoElement>(null)
// The REVEAL must not run until the fly-in has landed — otherwise the modal clip would appear
// over the top of the clone that is still flying. This clip is already playing by then (in sync
// with the clone); it is simply invisible, behind the transparent container. Deferred here and
// re-fired the moment `armed` flips.
const armedRef = useRef(armed)
const pendingRef = useRef(false)
const revealRef = useRef<(() => void) | null>(null)
useEffect(() => {
armedRef.current = armed
if (armed && pendingRef.current) { pendingRef.current = false; revealRef.current?.() }
}, [armed])
const onLoadRef = useRef(onLoad)
useEffect(() => { onLoadRef.current = onLoad })
const getCloneTimeRef = useRef(getCloneTime)
useEffect(() => { getCloneTimeRef.current = getCloneTime })
const [progress, setProgress] = useState(0)
const [ready, setReady] = useState(false)
const [bar, setBar] = useState<{ w: number; left: number; top: number } | null>(null)
// The clip's own dimensions size the loading shimmer to its contained rect. Seeded from the
// item's declared dimensions, then corrected from the modal video's own loadedmetadata — so a
// clip without dimensions still gets a clip-shaped shimmer once metadata lands.
const [dims, setDims] = useState<{ w: number; h: number } | null>(
item.width && item.height ? { w: item.width, h: item.height } : null,
)
const showBar = !!item.videoProgress
useEffect(() => {
const v = ref.current
if (!v) return
const onTime = () => { const d = v.duration; if (d && Number.isFinite(d)) setProgress(v.currentTime / d) }
// Match the bar to the video's contained rect. The clip is laid out in the top of the
// slide, leaving a 10px strip below (8px gap + 2px bar) so the bar never overlaps it.
const measure = () => {
const host = v.parentElement
if (!host) return
if (v.videoWidth && v.videoHeight) setDims({ w: v.videoWidth, h: v.videoHeight })
const cw = host.clientWidth, ch = host.clientHeight
const availH = ch - (showBar ? 10 : 0)
const a = (v.videoWidth || 16) / (v.videoHeight || 9)
let dw = cw, dh = cw / a
if (dh > availH) { dh = availH; dw = availH * a }
setBar({ w: dw, left: (cw - dw) / 2, top: (availH - dh) / 2 + dh + 8 })
}
// THIS CLIP RUNS IN SYNC WITH THE FLYING CLONE, from the moment it has metadata.
//
// It used to sit paused through the whole flight and only seek to the clone's live position at
// the handoff — and to keep the clone from drifting past the frame being handed over, the clone
// was PAUSED while that seek ran. Measured in Safari, that froze the picture for 175ms while
// the seek, the painted-frame wait, the React commit and the clone's two-frame overlap all
// stacked up: a visible split-second pause right after the animation completed.
//
// The stall existed only because the two clips were at different times and had to be
// reconciled. So don't let them diverge: both start from the same captured frame
// (item.videoTime) and both play at 1x from there, so they stay together on their own. The
// handoff is then a pure swap of two videos already showing the same thing — no seek, no pause,
// nothing to reconcile, and the clip never stops moving.
let started = false
let revealed = false
// COLD-CACHE GUARD: the reveal may not run until the seek to the clicked frame has LANDED.
//
// Setting currentTime updates the property immediately but seeks asynchronously — and on a
// cold cache a seek deep into the file has to fetch and decode a distant byte range, which
// takes long enough that requestVideoFrameCallback fires first with frame 0 still on screen.
// The reveal then dropped the clone (showing the correct clicked frame) over a modal video
// showing the START of the clip: the picture jumped backwards at the end of the fly-in, then
// jumped again when the seek landed. Warm caches seek instantly, which is why it only ever
// showed cold. FlightVideo has always gated on the landed seek; this is the same gate.
let seekTarget = priority && item.videoTime ? item.videoTime : 0
let seekLanded = seekTarget <= 0
let seekFailsafe: number | undefined
let resyncs = 0
const startPlayback = () => {
if (started) return
started = true
// Match the clone's starting frame. Only the clicked clip (priority) does this; neighbours
// rest at frame 0 until they are swiped to.
if (priority && item.videoTime && v.currentTime < 0.05) {
try {
v.currentTime = item.videoTime
// If `seeked` somehow never fires (a stream that can't land it), reveal anyway after
// 4s — the clone (the clip itself, playing) covers the whole wait, so the failsafe
// only trades a permanently-hidden modal video for a worst-case late swap.
seekFailsafe = window.setTimeout(() => { seekLanded = true; maybeReveal() }, 4000)
} catch {
seekLanded = true // not seekable yet — plays from 0; nothing better to wait for
}
} else {
seekLanded = true // no seek needed: the current frame is already the right frame
}
if (priority && !reduceMotion) v.play().catch(() => {})
}
let done = false
const reveal = () => {
if (done) return
done = true
// Seed the bar with the position the clip is ACTUALLY at, in the same commit it first mounts.
// It used to mount at 0 and only get a real value on the first `timeupdate`, and because the
// fill carries a width transition, that value then animated in from the left edge: the bar
// appeared empty and visibly chased the playhead. A freshly-inserted element does not
// transition its initial value, so setting it here means the bar's first paint is already in
// the right place; later timeupdates still glide.
const d = v.duration
if (d && Number.isFinite(d)) setProgress(v.currentTime / d)
setReady(true)
onLoadRef.current?.()
}
// Reveal on a PAINTED frame, never on an event.
//
// An event says the decoder is done, not that the frame is on screen — WebKit paints it on the
// next compositor tick. Measured in Safari: `seeked` at 592ms, the frame painted at 604ms.
// Handing the stage over inside that 12ms window (clone unmounted, modal video not yet showing
// anything) was a flash at the END of the open animation, exactly as painting frame 0 before
// the seek landed was the flash at the START (see FlightVideo). requestVideoFrameCallback fires
// only once a frame has actually been presented, so gating on it makes the swap frame-exact.
// The clip is playing by now, so frames keep coming and this always fires.
const revealOnPaintedFrame = () => {
if (revealed) return
revealed = true
const rvfc = (v as RVFCVideo).requestVideoFrameCallback
if (!rvfc) { reveal(); return } // no rVFC: this is the best signal available
const to = window.setTimeout(reveal, 120) // safety net if playback never starts
rvfc.call(v, () => { window.clearTimeout(to); reveal() })
}
// The single reveal funnel: seek landed AND flight landed (armed) AND in step with the
// clone, else park in pending. `revealRef` points HERE (not at revealOnPaintedFrame), so
// the armed-flip re-fire runs the same gates — it used to jump straight to the paint wait,
// which skipped the sync check below.
const maybeReveal = () => {
if (revealed || !seekLanded) return
// The flight has not landed yet: keep this clip playing BEHIND the clone (the container is
// still transparent) and reveal the moment `armed` flips.
if (!armedRef.current) { pendingRef.current = true; return }
// FINAL SYNC. "Both start from the clicked frame and play at 1x, so they stay together"
// is only true when both START at the same wall-clock moment. On a slow load this clip
// begins playing whenever its data arrives — behind the veil — while the clone has been
// showing the clicked frame (frozen poster) or its own live playback the whole time. By
// reveal time the two could be seconds apart, and the swap jumped: THE flash at the end
// of the open that survived the seek gate. So immediately before revealing, compare
// against the frame the clone is actually showing and land there first. Each pass loops
// back through `seeked` → here; three attempts is plenty (warm seeks converge in one).
const cloneT = priority ? getCloneTimeRef.current?.() : null
if (cloneT != null && Math.abs(v.currentTime - cloneT) > 0.15 && resyncs < 3) {
resyncs++
seekLanded = false
seekTarget = Math.max(0, Math.min(cloneT + 0.05, (Number.isFinite(v.duration) ? v.duration : Infinity) - 0.05))
try { v.currentTime = seekTarget } catch { seekLanded = true }
return
}
revealOnPaintedFrame()
}
revealRef.current = maybeReveal
const onSeeked = () => {
if (Math.abs(v.currentTime - seekTarget) > 0.25) return // an intermediate seek — keep waiting
window.clearTimeout(seekFailsafe)
seekLanded = true
maybeReveal()
}
const onReady = () => {
measure()
startPlayback() // in sync with the clone, from the first moment it can be
maybeReveal()
}
v.addEventListener('seeked', onSeeked)
v.addEventListener('timeupdate', onTime)
v.addEventListener('loadeddata', onReady)
v.addEventListener('canplay', onReady)
v.addEventListener('playing', onReady)
v.addEventListener('loadedmetadata', measure)
// Reveal precisely on the first painted frame where supported, so the clone→video swap is
// frame-exact (no flash).
type RVFCVideo = HTMLVideoElement & { requestVideoFrameCallback?: (cb: () => void) => number }
const rvfc = (v as RVFCVideo).requestVideoFrameCallback
if (rvfc) rvfc.call(v, () => onReady())
if (v.readyState >= 2) onReady() // frame 0 already decoded (e.g. bfcache)
if (v.videoWidth) measure()
const ro = new ResizeObserver(measure)
if (v.parentElement) ro.observe(v.parentElement)
return () => {
window.clearTimeout(seekFailsafe)
v.removeEventListener('seeked', onSeeked)
v.removeEventListener('timeupdate', onTime)
v.removeEventListener('loadeddata', onReady)
v.removeEventListener('canplay', onReady)
v.removeEventListener('playing', onReady)
v.removeEventListener('loadedmetadata', measure)
ro.disconnect()
}
}, [priority, item.videoSrc, item.videoTime, showBar, reduceMotion])
return (
<>
{!ready && <AssetShimmer w={dims?.w} h={dims?.h} />}
<video
ref={ref}
src={item.videoSrc}
muted
loop
playsInline
// Reduced motion: the clip stays paused on its synced frame, so the native controls are
// the play affordance (the lightbox has no other one). Their taps must not bubble to
// the root's tap-to-close.
controls={!!reduceMotion}
onClick={reduceMotion ? (e) => e.stopPropagation() : undefined}
preload={priority ? 'auto' : 'metadata'}
// No poster: the reveal waits for the clip to paint, so it shows frame 0 directly with
// no poster→video swap. A <video> is a replaced element, so left/right insets don't
// stretch it — size it explicitly, leaving a 10px strip for the bar when shown.
style={{ height: showBar ? 'calc(100% - 10px)' : '100%' }}
className="absolute inset-x-0 top-0 z-10 w-full object-contain"
/>
{/* Gated on `ready` — i.e. it mounts with the clip's real position already seeded (see
reveal()) — and eased in, so it resolves with the clip instead of snapping into existence
under it the instant the geometry is measured. */}
{showBar && bar && ready && (
<m.div
aria-hidden
className="absolute z-20 h-0.5 overflow-hidden"
style={{ width: bar.w, left: bar.left, top: bar.top, background: 'color-mix(in srgb, currentColor 22%, transparent)' }}
// Half a second, accelerating (easeIn), and it only starts once the fly-in has landed —
// the bar does not mount until the clip reveals, which is gated on the open animation
// finishing. So it emerges quietly under the settled clip rather than competing with it.
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, ease: 'easeIn' }}
>
<div className="h-full w-full origin-left" style={{ transform: `scaleX(${progress})`, background: 'var(--dk-accent)', transition: 'transform 0.25s linear' }} />
</m.div>
)}
</>
)
}
/** One image in the swipe track, offset by translateX so prev/current/next sit side by
* side. The base layer reuses the grid's already-loaded file (instant, from cache); a sharper
* variant (getHiResSrc) fades in on top once loaded — for every visible slide, so a swipe/nav
* always lands on a hi-res image. */
function Slide({ item, offset, priority, hiRes = true, getHiResSrc, onLoad, showOutline = true, armed = true, getCloneTime }: {
item: DKLightboxItem
offset: string
priority?: boolean
/** Load the sharp layer. Off for neighbours during the open animation so five big images
* don't decode at once and jitter the spring — they upgrade once the overlay has opened. */
hiRes?: boolean
getHiResSrc?: (item: DKMediaItem) => string
onLoad?: () => void
/** The 1px edge outline — see DKLightboxProps.showOutline. */
showOutline?: boolean
/** Has the fly-in landed? The clip plays behind the flying clone either way; this only gates
* the REVEAL, so the modal clip cannot appear on top of a clone still in the air. */
armed?: boolean
/** See LightboxVideo — the clone's currently-visible playhead, for the reveal-time sync. */
getCloneTime?: () => number | null
}) {
const [hiResLoaded, setHiResLoaded] = useState(false)
const [baseLoaded, setBaseLoaded] = useState(false)
// Video items play in place of the image layers (shimmer + progress bar live in
// LightboxVideo). Only the current slide (priority) autoplays; neighbours rest until
// swiped to, so opening the overlay never decodes three clips at once.
if (item.videoSrc) {
return (
<div className="absolute inset-0" style={{ transform: `translateX(${offset})` }}>
{/* The slide spans the full track width so it can travel fully off-screen; the asset is
inset 16px so it never bleeds to the very edge at rest (the margins live here, not on
the clipped container, so they don't cut the slide short). */}
<div className="absolute inset-y-0 inset-x-4">
<LightboxVideo item={item} priority={priority} onLoad={onLoad} armed={armed} getCloneTime={getCloneTime} />
</div>
</div>
)
}
// The base layer prefers flightSrc — the exact file the origin <img> had already decoded,
// served verbatim — so the track is painted before the fly-in lands even on a cold cache.
const baseSrc = item.flightSrc || item.src
// The sharper large variant, if the host supplies one (e.g. a CDN resize URL). Skipped when
// it resolves to the file already on screen — nothing to upgrade to.
const hiResUrl = getHiResSrc?.(item)
const showHiRes = !!hiResUrl && hiResUrl !== baseSrc
const loading = priority ? undefined : ('eager' as const)
return (
<div className="absolute inset-0" style={{ transform: `translateX(${offset})` }}>
{/* Full-width slide (travels fully off-screen), asset inset 16px so it keeps its margins at
rest without the container's clip cutting the slide short mid-animation. */}
<div className="absolute inset-y-0 inset-x-4">
{!baseLoaded && <AssetShimmer w={item.width} h={item.height} />}
<img
src={baseSrc}
alt={item.alt ?? item.caption ?? ''}
className="absolute inset-0 z-10 h-full w-full object-contain"
loading={loading}
decoding={priority ? 'sync' : 'async'}
draggable={false}
onLoad={() => { setBaseLoaded(true); onLoad?.() }}
/>
{hiRes && showHiRes && (
<img
src={hiResUrl}
alt=""
aria-hidden
className="absolute inset-0 z-20 h-full w-full object-contain"
loading={loading}
decoding="async"
draggable={false}
// decode() before revealing: onLoad means the BYTES arrived, not that the large
// bitmap is decoded — fading it in straight from onLoad forced decode during the
// opacity ramp, the little hitch visible on slow connections as the sharp layer
// swapped in. decode() runs off the main thread and resolves with the bitmap
// ready, so the fade is then a pure composited opacity ramp. Falls back to
// revealing anyway if decode() is unsupported or rejects.
onLoad={(e) => {
const img = e.currentTarget
const decoded: Promise<void> = img.decode ? img.decode().catch(() => {}) : Promise.resolve()
decoded.then(() => setHiResLoaded(true))
}}
style={{ opacity: hiResLoaded ? 1 : 0, transition: 'opacity 300ms ease-out' }}
/>
)}
{showOutline && <AssetOutline w={item.width} h={item.height} />}
</div>
</div>
)
}
/** The still frame under a flying video clone. It is what guarantees the clone always has
* something painted: `flightPoster` (a canvas snapshot of the exact clicked frame) when the clip
* had decoded a frame, else the item's own poster image, which always exists. The live <video>
* layers on top and covers this as soon as it has a real frame. Without it, the clone is
* transparent while the video seeks — a hole showing the scrim through it. */
function FlightStill({ item, onPainted, hidden = false, repaintRef }: {
item: DKLightboxItem
onPainted: () => void
hidden?: boolean
/** Parent-held hook: repaint this still from the live clone <video>, so it can come BACK at
* the handoff showing the current frame instead of the stale click frame (see below). */
repaintRef?: { current: ((v: HTMLVideoElement) => void) | null }
}) {
// A CANVAS rather than an <img>, so its pixels can be refreshed. The still starts as the
// click-frame snapshot (flightPoster) and its only job used to end the moment the live video
// painted — it went to opacity 0 and stayed there, because Safari transiently drops video
// layers while re-compositing and this STALE frame showing through read as the picture
// jumping backwards. But hiding it opened a worse hole: at the handoff re-composite (modal
// container promoted, clone eventually dropped) a transiently-dropped video layer now had
// NOTHING beneath it but the scrim — the intermittent WHITE flash at the end of the open.
// The parent now repaints this canvas from the clone's live frame right before the handoff
// and un-hides it, so every re-composite window has current pixels underneath.
const canvasRef = useRef<HTMLCanvasElement>(null)
const src = item.flightPoster || item.src
const onPaintedRef = useRef(onPainted)
useEffect(() => { onPaintedRef.current = onPainted })
useEffect(() => {
if (!src) return
const img = new window.Image()
img.onload = () => {
const c = canvasRef.current
if (!c) return
c.width = img.naturalWidth
c.height = img.naturalHeight
c.getContext('2d')?.drawImage(img, 0, 0)
onPaintedRef.current()
}
img.src = src
}, [src])
useEffect(() => {
if (!repaintRef) return
repaintRef.current = (v: HTMLVideoElement) => {
const c = canvasRef.current
if (!c || !v.videoWidth) return
if (c.width !== v.videoWidth) { c.width = v.videoWidth; c.height = v.videoHeight }
try { c.getContext('2d')?.drawImage(v, 0, 0) } catch { /* cross-origin frame — keep poster */ }
}
return () => { repaintRef.current = null }
}, [repaintRef])
if (!src) return null
return (
<canvas
ref={canvasRef}
aria-hidden
className="absolute inset-0 h-full w-full object-contain"
style={{ opacity: hidden ? 0 : 1 }}
/>
)
}
/** The live <video> inside the fly-in clone — held INVISIBLE until it has painted the frame that
* was actually clicked.
*
* A fresh <video> does not wait to be seeked before it starts painting. Measured: the clone
* paints frame 0 at ~45ms, and its seek to the clicked timestamp only lands at ~106ms. For those
* ~60ms the flying clone showed the START of the clip at full opacity, on top of the correct
* still, and then cut back — a flash of wrong CONTENT, not animation, which is why it survived
* every change to the spring and why slow-mo didn't slow it down. Short loops never show it
* (frame 0 is near enough the clicked frame); long clips always did.
*
* FlightStill (the exact clicked frame) is already underneath, so hiding the video until it lands
* costs nothing: the swap is then pixel-identical and imperceptible. */
function FlightVideo({ item, videoRef, onShown }: { item: DKLightboxItem; videoRef: { current: HTMLVideoElement | null }; onShown?: () => void }) {
const reduceMotion = useReducedMotion()
const ref = useRef<HTMLVideoElement>(null)
const [shown, setShown] = useState(false)
const onShownRef = useRef(onShown)
useEffect(() => { onShownRef.current = onShown })
useEffect(() => { if (shown) onShownRef.current?.() }, [shown])
useEffect(() => {
const v = ref.current
if (!v) return
videoRef.current = v
type RVFCVideo = HTMLVideoElement & { requestVideoFrameCallback?: (cb: () => void) => number }
const rvfc = (v as RVFCVideo).requestVideoFrameCallback?.bind(v)
const target = item.videoTime ?? 0
// Clicked at the very start (or a clip with no captured time): frame 0 IS the right frame.
let landed = target <= 0
const onPaint = () => { if (landed) setShown(true); else rvfc?.(onPaint) }
const onSeeked = () => {
if (Math.abs(v.currentTime - target) > 0.25) return // an intermediate seek — keep waiting
landed = true
if (rvfc) rvfc(onPaint) // reveal on the first frame PAINTED after the seek, not on the event
else setShown(true) // no rVFC: `seeked` is the best signal available
}
v.addEventListener('seeked', onSeeked)
if (rvfc) rvfc(onPaint)
else if (landed) setShown(true)
return () => {
v.removeEventListener('seeked', onSeeked)
if (videoRef.current === v) videoRef.current = null
}
}, [item.videoTime, videoRef])
return (
<video
ref={ref}
src={item.videoSrc}
muted
playsInline
loop
preload="auto"
onLoadedMetadata={(e) => {
const v = e.currentTarget
if (item.videoTime) v.currentTime = item.videoTime
if (!reduceMotion) v.play().catch(() => {})
}}
// No transition: this is a cut between identical pixels (the still and the clip's own frame),
// so a cross-fade would only make it visible.
style={{ opacity: shown ? 1 : 0 }}
className="absolute inset-0 z-10 h-full w-full object-contain"
/>
)
}
export function DKLightbox({ item, index, total, onClose, onPrev, onNext, originRect, prevItem, nextItem, hasCaptions = true, showOutline = true, slowMo = false, getHiResSrc, onFlightPainted, onSettled }: DKLightboxProps) {
const caption = item.caption ?? item.alt ?? null
// Every color routes through the --dk-* tokens (the root below carries .dk-scope, so they
// resolve here). The two effects that paint OUTSIDE this subtree — the <html>/<body> pin and
// the theme-color meta — read the resolved literal via getComputedStyle instead.
const FG = 'var(--dk-fg)'
const BG = 'var(--dk-bg)'
const reduceMotion = useReducedMotion()
// Reduced motion wins over slow-mo: the fly-in becomes the settle-only spring
// (ported from photos-lightbox, 2026-08-28 drift audit).
const openSpring = reduceMotion ? NAV_SPRING_REDUCED : slowMo ? SLOW_OPEN_SPRING : OPEN_SPRING
const rootRef = useRef<HTMLDivElement>(null)
const scrimRef = useRef<HTMLDivElement>(null)
// The fly-in clone's <video> when the asset is a clip — the modal video reads its LIVE
// currentTime at handoff so playback continues from wherever the flight actually ended.
const cloneVideoRef = useRef<HTMLVideoElement>(null)
// Has the clone's live <video> actually painted? Once it has, the stale still beneath it is
// dropped (see FlightStill) — it can only do harm from that point on.
const [cloneVideoShown, setCloneVideoShown] = useState(false)
// Ref mirror for getCloneTime, which is read from inside LightboxVideo's long-lived effect.
const cloneVideoShownRef = useRef(false)
useEffect(() => { cloneVideoShownRef.current = cloneVideoShown }, [cloneVideoShown])
// The playhead of the frame the CLONE is currently showing: its live video's time once that
// has painted, else the frozen poster's captured time. The modal clip syncs to this right
// before it reveals (see the FINAL SYNC note in LightboxVideo).
const getCloneTime = useCallback(() => {
const cv = cloneVideoRef.current
if (cv && cloneVideoShownRef.current) return cv.currentTime
return item.videoTime ?? 0
}, [item.videoTime])
// First-real-frame signal for the clone — fired once; the opener hides the origin element
// on it, so the origin→clone handoff is paint-to-paint with no blink.
const flightPaintedRef = useRef(false)
// Also state, because the page-fade loop below must not START until this is true: the page may
// not begin disappearing before the thing replacing it is on screen.
const [flightPainted, setFlightPainted] = useState(false)
const fireFlightPainted = () => {
if (flightPaintedRef.current) return
flightPaintedRef.current = true
setFlightPainted(true)
onFlightPainted?.()
}
const containerRef = useRef<HTMLDivElement>(null)
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
const [animDone, setAnimDone] = useState(!originRect)
// Hold the spring for ONE painted frame before it starts.
//
// motion begins the open animation on the same frame React commits the whole modal — three
// slides, a <video preload="auto">, and possibly a large data-URL poster to decode. That commit
// can take ~150ms, so the spring's FIRST rAF arrived with a ~150ms delta and integrated straight
// to 79% of the travel in a single frame: the clip appeared to snap to full size and then creep
// the last 20%, with a 4px overshoot correction at the end. Measured off a 60fps screen
// recording, that is exactly the "jitter right after I click" and the "jitter at the end".
//
// Gating on a double-rAF lets the expensive commit land, then starts the spring on a clean
// frame with a normal ~16ms delta, so it actually springs.
const [fly, setFly] = useState(false)
useEffect(() => {
if (!originRect) return
const id = requestAnimationFrame(() => requestAnimationFrame(() => setFly(true)))
return () => cancelAnimationFrame(id)
}, [originRect])
const [imageLoaded, setImageLoaded] = useState(false)
// Images: reveal as soon as the fly-in lands, so the shimmer covers a slow load. Videos:
// hold the clone — which is the clip itself, still PLAYING — until the modal video has
// sought to the clone's live position and is painting, so the swap lands on the same frame.
const containerVisible = !originRect || (animDone && (!item.videoSrc || imageLoaded))
// Hold the flying clone on screen for two painted frames AFTER the modal is revealed, then drop
// it. The clone is the same pixels, opaque, frozen, and pointer-events-none, so the overlap is
// invisible — but the gap it covers is not.
//
// Measured in Safari, 60fps: at the handoff there is exactly ONE frame where the whole clip is
// uniformly washed out (region mean 38.8 -> 67.6 -> 38.1, which solves to the clip at ~87% over
// the white scrim). Nothing in the tree has an 87% opacity, and it survived making the reveal
// wait for a painted frame — it is Safari re-compositing the video layer as the clone unmounts
// and the modal video takes over, during which the new layer is briefly not fully opaque and the
// scrim shows through it. Chrome never does this.
//
// There is no way to make Safari promote the layer faster, so instead nothing is ever uncovered:
// the clone stays on top across the swap.
const [cloneGone, setCloneGone] = useState(false)
const cloneGoneRef = useRef(false)
const onSettledRef = useRef(onSettled)
useEffect(() => { onSettledRef.current = onSettled })
// Hide the clone (it stays MOUNTED — see the render note) and declare the open settled.
// Shared by the overlap timer below and commit(), which must drop the clone instantly when a
// nav starts sliding the track underneath it.
const settleClone = useCallback(() => {
if (cloneGoneRef.current) return
cloneGoneRef.current = true
setCloneGone(true)
// The clone's job is over; its video needn't keep decoding in parallel with the modal's.
cloneVideoRef.current?.pause()
// The open has fully settled: the modal owns the screen. The opener restores the hidden
// origin element on this signal (see DKLightboxProps.onSettled).
onSettledRef.current?.()
}, [])
// The overlap grew from two painted frames to 300ms. Two frames covered Chrome, but Safari's
// transient layer washout at the handoff (see below) can outlive them — and since the clone
// and the modal are frame-locked by reveal time (see LightboxVideo's FINAL SYNC), a longer
// overlap is invisible. It ends EARLY the moment a nav needs the track (commit calls
// settleClone), so interaction never fights it.
useEffect(() => {
if (!containerVisible || cloneGone) return
// 300ms ONLY for clips (the Safari video-layer washout the overlap exists for; clone and
// modal are frame-locked so it's invisible). Images get the original two-frames-worth:
// an image clone fits by the THUMBNAIL's aspect, and when that differs a hair from the
// slide's intrinsic-aspect letterbox, a long overlap shows two sizes stacked.
const id = window.setTimeout(settleClone, item.videoSrc ? 300 : 35)
return () => window.clearTimeout(id)
}, [containerVisible, cloneGone, settleClone, item.videoSrc])
// At the moment of the handoff, refresh the still UNDER the clone's video with the clone's
// current frame and bring it back (FlightStill was hidden once the live video painted). Every
// layer Safari might transiently drop during the handoff re-composite now has current pixels
// beneath it instead of the white scrim.
const stillRepaintRef = useRef<((v: HTMLVideoElement) => void) | null>(null)
const [stillResurfaced, setStillResurfaced] = useState(false)
// The still comes back the moment the container is visible (render adjustment)…
if (containerVisible && !stillResurfaced) setStillResurfaced(true)
// …with FRESH pixels: the repaint runs pre-paint (layout effect) in that same commit,
// so the resurfaced still never paints a stale frame.
const stillRepaintedRef = useRef(false)
useLayoutEffect(() => {
if (!containerVisible || stillRepaintedRef.current) return
const cv = cloneVideoRef.current
if (cv) stillRepaintRef.current?.(cv)
stillRepaintedRef.current = true
}, [containerVisible])
// Where the flying-clone open lands: the photo's contain-fitted rect inside the container.
// Aspect comes from the thumbnail's rect (originRect) — always known, whereas declared
// width/height may be absent, which would make the fit NaN and the photo never reveal.
// Reserve the same 10px strip a progress-bar clip leaves at the bottom, so the clone lands
// exactly where the video ends up (no resize when the bar appears).
const barReserve = item.videoSrc && item.videoProgress ? 10 : 0
// Each slide insets its asset 16px per side (inset-x-4 in Slide), so the flight must land
// inside that same box. Fitting to the full container let a width-bound asset (a landscape
// photo or clip on a phone) fly to full-bleed and then snap to its real, inset width the
// moment the track revealed.
const SLIDE_INSET = 16
const cloneTarget = targetRect && originRect
? containRect(
{
left: targetRect.left + SLIDE_INSET,
top: targetRect.top,
width: targetRect.width - SLIDE_INSET * 2,
height: targetRect.height - barReserve,
},
originRect.width,
originRect.height,
)
: null
// Caption/exif follow the committed item but update in step with the slide (not at
// its end), so they don't appear to lag the image. Re-synced to `item` on prop change.
const [captionItem, setCaptionItem] = useState(item)
// Adjust during render, not in an effect: the caption flips in the SAME commit as the
// slide, never a paint behind it.
if (captionItem !== item) setCaptionItem(item)
const capCaption = captionItem.caption ?? null
const capExif = formatExif(captionItem.exif)
// Desktop off-photo detection: the glass material exists for text ON the photo. When
// the contain-fitted photo doesn't reach the card (short landscape photo, tall window),
// the card drops to theme ink — the same treatment mobile always uses. Geometry is
// computed (contain fit vs the card's text top) rather than measured off the img, so
// it works mid-flight, after nav, and independent of image load state.
const [capOnPhoto, setCapOnPhoto] = useState(false)
// Same verdict, extended to the chrome buttons: ink buttons got lost on dark photos.
// Each control earns glass on ITS OWN axes — the arrows when the photo is wide
// enough to run under them, the close when it reaches the top-right corner.
const [chromeOnPhoto, setChromeOnPhoto] = useState({ close: false, prev: false, next: false })
const prevBtnRef = useRef<HTMLButtonElement | null>(null)
const nextBtnRef = useRef<HTMLButtonElement | null>(null)
const capCardRef = useRef<HTMLDivElement | null>(null)
const measureCapRef = useRef<() => void>(() => {})
useEffect(() => {
const measure = () => {
// Contain-fit math from LIVE rects — never the img's own rect: the slide <img>
// fills the asset box (object-contain), so its element rect is the full-height
// area and its bottom sits at the viewport bottom no matter how short the photo
// renders. The container rect already carries every inset the layout owns; the
// only constant left is the slide's structural 16px side margins (inset-x-4).
const box = containerRef.current?.getBoundingClientRect()
const dock = dockRef.current
const root = rootRef.current
const wrap = capCardRef.current?.parentElement?.getBoundingClientRect()
if (!box || !wrap || !dock || !root || box.height === 0) return false
// Declared dimensions may be absent on an item — without an aspect there is no
// verdict, and ties go to ink (glass is the exception that needs to earn itself).
if (!captionItem.width || !captionItem.height) {
setCapOnPhoto(false)
setChromeOnPhoto({ close: false, prev: false, next: false })
return true
}
const assetW = Math.max(0, box.width - SLIDE_INSET * 2)
// captionItem's aspect, NOT the item prop's: captionItem flips synchronously at a
// nav commit — it IS the photo the card is captioning, which is the one the
// material must match, even while the slide is still travelling.
const dispH = Math.min(box.height, assetW * (captionItem.height / captionItem.width))
const photoBottom = box.top + (box.height + dispH) / 2
// Compare against the card's RESTING position, not where it currently is: during
// the entrance the card is still below the fold, and measuring it there said
// "off photo" until the animation ended — the glass popped in at landing. The
// rest is knowable from frame one: the dock parks at the root's bottom edge
// (offsetHeight ignores the entrance transform), and the wrapper's offset inside
// the dock is transform-invariant.
const dockRect = dock.getBoundingClientRect()
const wrapRestTop = root.getBoundingClientRect().bottom - dock.offsetHeight + (wrap.top - dockRect.top)
setCapOnPhoto(photoBottom > wrapRestTop + 10)
// Chrome buttons, same contain-fit geometry on their own axes. Rest positions via
// the same transform-invariant offset math (entrance-proof); a button is "on
// photo" only when the photo underlaps most of its 44px circle (8px grace).
const rootRect = root.getBoundingClientRect()
const dispW = Math.min(assetW, box.height * (captionItem.width / captionItem.height))
const photoLeft = box.left + SLIDE_INSET + (assetW - dispW) / 2
const photoRight = photoLeft + dispW
const photoTop = box.top + (box.height - dispH) / 2
const gutter = parseFloat(getComputedStyle(root).getPropertyValue('--dk-sb-gutter')) || 0
const restTopOf = (el: HTMLElement | null) => {
if (!el) return Infinity
return rootRect.bottom - dock.offsetHeight + (el.getBoundingClientRect().top - dockRect.top)
}
const arrowOn = (el: HTMLButtonElement | null) => {
if (!el) return false
const r = el.getBoundingClientRect()
const vert = photoBottom > restTopOf(el) + 8
// Horizontal: NEAR the photo counts as on it (16px grace) — a bare ink arrow
// floating a few pixels off a photo edge reads worse than glass slightly over
// the gutter. Overrides ties-go-to-ink at the edges only.
return vert && photoLeft < r.right + 16 && photoRight > r.left - 16
}
setChromeOnPhoto({
close: photoTop < rootRect.top + 12 + 44 - 8 && photoRight > rootRect.right - (16 + gutter) - 44 + 8,
prev: arrowOn(prevBtnRef.current),
next: arrowOn(nextBtnRef.current),
})
return true
}
measureCapRef.current = measure
// Retry every frame until the layout is measurable: on a COLD open the dock and
// caption card mount a beat after the dialog, so a single post-swap rAF hit the
// ref guard, bailed silently, and the verdict sat at its ink default until the
// 450ms backstop landed it — the glass (blur included) popped in a second after
// the fly-in settled.
let raf = requestAnimationFrame(function tick() {
let tries = 0
const run = () => {
if (measure() === false && tries++ < 60) raf = requestAnimationFrame(run)
}
run()
})
const late = window.setTimeout(measure, 450) // after the nav spring lands
window.addEventListener('resize', measure)
return () => { cancelAnimationFrame(raf); window.clearTimeout(late); window.removeEventListener('resize', measure) }
}, [item, captionItem])
// Publish the dock's live height as --dk-dock-h so the mobile media area can reserve
// space under the photo (see max-sm:pb below). Measured, not guessed: caption length
// and wrap count vary per item. offsetHeight ignores the entrance transform.
// LAYOUT effect with a synchronous first apply, and declared BEFORE the targetRect
// measurement: the fly-in target must be measured with the padding already in place,
// or the clone lands in the unpadded area and the photo jumps up at handoff.
const dockRef = useRef<HTMLDivElement | null>(null)
// Has the dock's entrance finished? While false, the dock's children carry the
// .dk-dock-enter rise animation; flipping true removes the class so the keyed
// caption card can remount on nav without replaying the entrance.
// The entrance is a pure opacity fade (no translate): the rise was cut when the
// chrome moved to entering at the clone handoff — a hard appear read as a glitch,
// and a transform on the entering card is exactly what WebKit punishes hardest on
// backdrop-filter. (Safari still withholds the blur until any animation on the
// element finishes, so the glass sharpens at the fade's end there — 350ms, subtle.)
const [dockEntered, setDockEntered] = useState(false)
// Held invisible until the clone handoff (animDone) — see the .dk-dock-hold comment
// at the close button. The fade plays from the handoff, not from mount.
const dockRiseClass = !animDone ? 'dk-dock-hold ' : dockEntered ? '' : 'dk-dock-fade '
useLayoutEffect(() => {
const dock = dockRef.current
const root = rootRef.current
if (!dock || !root) return
const apply = () => root.style.setProperty('--dk-dock-h', `${dock.offsetHeight}px`)
apply()
const ro = new ResizeObserver(apply)
ro.observe(dock)
return () => ro.disconnect()
}, [])
// Swipe track: `x` follows the finger horizontally (commit = navigate); `yDrag`
// follows a downward drag to dismiss. `axis` locks to whichever the finger leads with.
const x = useMotionValue(0)
const yDrag = useMotionValue(0)
// Fades to FULLY transparent by 280px: the release animation only needs to travel far
// enough for the content to dissolve, not escort it to the bottom of the screen.
const dragOpacity = useTransform(yDrag, [0, 280], [1, 0])
// GLASS FRESHNESS. WebKit takes a SAMPLE of the backdrop behind the card and can
// keep serving it when the only thing changing underneath is a composited transform
// — exactly what a slide is — so after a nav the glass kept the PREVIOUS photo's
// colors (Chromium re-samples fine). Every frame the track moves, nudge the card's
// backdrop-filter between two imperceptibly different blur radii: a filter change
// forces a fresh sample, so the glass is live during the slide and the settle frame
// (itself a change) is always fresh. Inline overrides only while the glass CSS is
// actually in effect (data-on-photo + the sm breakpoint) — an inline filter on the
// ink card would conjure phantom glass.
const nudgeGlassRef = useRef<() => void>(() => {})
useEffect(() => {
let flip = 0
const nudge = () => {
const el = capCardRef.current
if (!el) return
if (!el.hasAttribute('data-on-photo') || window.innerWidth < 640) {
el.style.removeProperty('-webkit-backdrop-filter')
el.style.removeProperty('backdrop-filter')
return
}
flip ^= 1
const v = flip ? 'blur(20.02px) saturate(1.8)' : 'blur(20px) saturate(1.8)'
el.style.setProperty('-webkit-backdrop-filter', v)
el.style.setProperty('backdrop-filter', v)
}
nudgeGlassRef.current = nudge
return x.on('change', nudge)
}, [x])
// Backstop for change paths the track never sees: the entrance (x is idle during the
// open), the hi-res layer's opacity fade after a nav, a slow-mo entrance's late end.
useEffect(() => {
const ts = [450, 1200, 4200].map((ms) => window.setTimeout(() => nudgeGlassRef.current(), ms))
return () => ts.forEach((t) => window.clearTimeout(t))
}, [captionItem])
// SWIPE DOWN UNDOES THE OPEN, rather than just sliding the photo away.
//
// Opening the overlay fades the page content OUT and the backdrop scrim IN (see the page-fade
// effect). Dismissing did neither in reverse: the photo slid down and faded, but the scrim stayed
// fully opaque the whole way, so the page never came back — it simply appeared, all at once, when
// the modal unmounted. Dragging down now runs the open transition BACKWARDS in step with the
// finger: the scrim dissolves and the page underneath fades up, so you are literally pulling the
// page back into view. Let go early and it springs back, taking the reveal with it.
// The in-document veil the iOS bottom bar actually blurs — created by the backdrop
// effect below; the swipe-down reveal drives its opacity back out.
const veilRef = useRef<HTMLDivElement | null>(null)
const REVEAL_DRAG = 260 // px of downward travel that fully restores the page
// The open page-fade's rAF id, so a downward drag can take the opacity channel over from it
// (see below). Written every frame by the page-fade loop.
const openFadeRafRef = useRef(0)
useEffect(() => {
// NOT gated on animDone. It used to be ("the open is still running the same properties;
// don't fight it") — but on a phone the common gesture is tap, then swipe down immediately,
// BEFORE the ~350ms fly-in lands. With the subscription not yet made, the drag moved the
// photo while the page stayed frozen at whatever opacity the open fade had reached, then
// snapped visible when the modal unmounted — "it just instantly reappears". The fight is
// resolved the other way now: at rest (v=0) this never writes, and the moment a real drag
// begins it CANCELS the open fade's rAF loop and owns the opacity channel from there.
const scrim = scrimRef.current
const apply = (v: number) => {
if (v <= 0) return // at rest: leave the open fade alone
cancelAnimationFrame(openFadeRafRef.current)
const t = Math.min(1, Math.max(0, v / REVEAL_DRAG))
if (scrim) scrim.style.opacity = String(1 - t)
// The veil — the in-document layer the iOS bottom bar blurs — tracks the scrim exactly,
// so the strip behind the bar fades with the finger like the rest of the modal.
if (veilRef.current) veilRef.current.style.opacity = String(Math.max(VEIL_MIN_OPACITY, 1 - t))
}
return yDrag.on('change', apply)
}, [yDrag])
const trackRef = useRef<HTMLDivElement>(null)
const startX = useRef(0)
const startY = useRef(0)
const axis = useRef<null | 'x' | 'y'>(null)
// Where the gesture crossed the 8px recognition threshold. Movement is measured FROM here, so the
// asset never jumps to catch up with the finger — it starts under it and stays under it.
const lock = useRef<{ x: number; y: number } | null>(null)
const busy = useRef(false)
useLayoutEffect(() => {
const body = document.body
const root = rootRef.current
const prevOverflow = body.style.overflow
// Lock scroll. If the host page sets `html { scrollbar-gutter: stable }`, that is deliberately
// left ALONE: it keeps the ~15px gutter reserved whether or not the scrollbar shows, so nothing
// shifts when overflow is hidden. The reserved gutter would leave a strip to the right of the
// modal, so size the modal to the PHYSICAL viewport width: window.innerWidth spans the gutter,
// whereas 100vw stops short of it when a gutter is reserved. Kept in sync on resize; cleared on
// close.
body.style.overflow = 'hidden'
// --dk-sb-gutter: how far innerWidth overhangs the visible layout viewport (the
// reserved scrollbar strip). The close button and the dock's right padding add it,
// so the chrome hugs the CONTENT edge, not the physical window edge under the
// scrollbar. Fine pointers only: touch scrollbars are overlays with no gutter.
const fit = () => {
if (!root) return
root.style.width = `${window.innerWidth}px`
const gutter = window.matchMedia('(pointer: fine)').matches
? Math.max(0, window.innerWidth - document.documentElement.clientWidth)
: 0
root.style.setProperty('--dk-sb-gutter', `${gutter}px`)
}
fit()
window.addEventListener('resize', fit)
return () => {
body.style.overflow = prevOverflow
window.removeEventListener('resize', fit)
if (root) root.style.width = ''
}
}, [])
// A true modal for the keyboard, not just the eye. aria-modal announces the page as
// inaccessible but doesn't make it so: Tab still walked the faded-out page underneath (its
// controls sit at opacity 0 for the Safari toolbar fix — focusable yet fully invisible).
// `inert` on every other <body> child makes the browser enforce the boundary natively —
// unfocusable, unclickable, hidden from assistive tech — so Tab cycles the dialog's own
// controls. Focus moves onto the dialog on open; the opener hands it back to the asset's
// element on close.
useEffect(() => {
const root = rootRef.current
const touched: HTMLElement[] = []
for (const el of Array.from(document.body.children)) {
if (el === root || !(el instanceof HTMLElement) || el.inert) continue
el.inert = true
touched.push(el)
}
root?.focus({ preventScroll: true })
return () => touched.forEach((el) => { el.inert = false })
}, [])
// Chrome/Android and pre-26 Safari tint their toolbar from <meta name="theme-color"> (Safari 26
// itself ignores it — that case is handled by the <html>/<body> background below). A host site's
// existing metas may be a prefers-color-scheme pair that (a) doesn't track class dark mode and
// (b) can out-rank a plain override. While open, remove those and install a single meta pinned to
// the modal's exact background; restore on close. The literal is read from the resolved --dk-bg
// (the meta lives outside the .dk-scope subtree, so the var itself can't be used).
useLayoutEffect(() => {
const root = rootRef.current
const bg = root ? getComputedStyle(root).getPropertyValue('--dk-bg').trim() : ''
if (!bg) return
const head = document.head
const saved = Array.from(head.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]'))
saved.forEach((el) => el.remove())
const meta = document.createElement('meta')
meta.name = 'theme-color'
meta.content = bg
head.appendChild(meta)
return () => { meta.remove(); saved.forEach((el) => head.appendChild(el)) }
}, [])
// iOS 26 Safari dropped `theme-color` and derives its translucent toolbar tint from the
// <html>/<body> background-color, so pin BOTH to the modal's exact colour while open — that's
// what the bar samples. When the host page's background matches --dk-bg (the default pairing),
// the pin is visually a no-op; either way the toolbar samples the modal's colour. Restored on
// close. (Same subtlety as the meta: html/body sit outside .dk-scope, so the resolved literal
// is written, not the var.)
useLayoutEffect(() => {
const root = rootRef.current
const bg = root ? getComputedStyle(root).getPropertyValue('--dk-bg').trim() : ''
if (!bg) return
const html = document.documentElement
const prevBodyBg = document.body.style.backgroundColor
const prevHtmlBg = html.style.backgroundColor
document.body.style.backgroundColor = bg
html.style.backgroundColor = bg
return () => {
document.body.style.backgroundColor = prevBodyBg
html.style.backgroundColor = prevHtmlBg
}
}, [])
// Safari's translucent bottom bar blurs the scrolled DOCUMENT beneath it, and a fixed
// overlay does NOT occlude that view — so no matter how opaque the modal was, the page kept
// peering through the bar. The cure used to be fading every other <body> child to ~0 and
// letting the bar blur the bare modal-coloured body. That held until the swipe-down reveal
// exposed its weakness: the bar rebuilds its blur of REAPPEARING in-document content
// LAZILY (~1s), so the strip behind it sat as a flat modal-coloured box and popped late —
// while a tap-close (instant unmount) updated instantly. Warm-layer opacity floors and
// post-teardown repaint nudges did not move it; reappearing content is simply the slow path.
//
// So nothing reappears anymore. The page stays at FULL opacity the whole time, and a VEIL —
// an absolutely-positioned in-document element (NOT fixed, so the bar provably samples it)
// covering the viewport behind the modal — carries the modal colour instead. The open fades
// the veil IN over the unchanged page (perceived content visibility is (1-eased)² either
// way — scrim over veil now, scrim over fading page before — so the look is identical), and
// the swipe-down reveal fades it OUT: a DISAPPEARING in-document layer, the direction the
// bar's backdrop handles live. A tap-close removes it with the unmount, which was already
// instant. The veil floors at VEIL_MIN_OPACITY so its layer never tears down mid-session.
useLayoutEffect(() => {
const root = rootRef.current
if (!root || root.parentElement !== document.body) return
const scrim = scrimRef.current
const veil = document.createElement('div')
veil.setAttribute('aria-hidden', 'true')
// Viewport-covering with 200px of slack both ends (bar geometry, rubber-banding, rotation
// mid-gesture); re-fitted on resize. Scroll is locked while open, so top stays valid.
const fitVeil = () => {
veil.style.top = `${window.scrollY - 200}px`
veil.style.height = `${window.innerHeight + 400}px`
}
Object.assign(veil.style, {
position: 'absolute',
left: '0',
right: '0',
// Under the modal (z-50), above everything the page stacks (the header is 45).
zIndex: '49',
pointerEvents: 'none',
// The scrim's own resolved colour — the veil is the scrim's in-document twin.
backgroundColor: scrim ? getComputedStyle(scrim).backgroundColor : '#fff',
opacity: !originRect || reduceMotion ? '1' : String(VEIL_MIN_OPACITY),
})
fitVeil()
window.addEventListener('resize', fitVeil)
document.body.appendChild(veil)
veilRef.current = veil
return () => {
window.removeEventListener('resize', fitVeil)
veil.remove()
veilRef.current = null
// Belt and suspenders for any straggler blur the bar still holds after teardown: two
// frames later (after the opener's exact scroll restore), move the page 1px and put it
// back — the backdrop provably tracks scroll.
requestAnimationFrame(() => requestAnimationFrame(() => {
const sy = window.scrollY
window.scrollTo(window.scrollX, sy > 0 ? sy - 1 : sy + 1)
requestAnimationFrame(() => window.scrollTo(window.scrollX, sy))
}))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// The open transition: one main-thread rAF loop fades the scrim and the veil in together.
// Main-thread inline writes, not WAAPI/CSS: the bar samples the main-thread paint, so a
// composited fade would read there as a cut. Deep-link opens and reduced motion skip the
// loop entirely (the veil mounted at 1, the scrim keeps its default opaque state).
useLayoutEffect(() => {
if (!originRect || reduceMotion) return
const scrim = scrimRef.current
const veil = veilRef.current
// THE PAGE MAY NOT START DISAPPEARING BEFORE ITS REPLACEMENT IS ON SCREEN: the loop is
// gated on the clone's first painted frame (flightPainted). The scrim, whose default
// state is opaque, is made transparent HERE — before the gate — or the whole screen goes
// solid for the ~40ms until the clone paints. That placement is load-bearing.
if (scrim) scrim.style.opacity = '0' // start transparent; the loop eases it back to 1
if (!flightPainted) return
// 550ms, up from 350: at 350 with the cubic ease the page was two-thirds gone 105ms after
// the click — it read as the page being yanked away rather than receding behind the photo.
const DURATION = slowMo ? 3000 : 550 // shift-click slow-mo stretches the fade with the spring
// Integrate CLAMPED deltas rather than reading the wall clock: Safari drops frames through
// the modal's first commit, and a wall-clock fade teleports across the gap — a stall can
// never turn this dissolve into a cut.
let last = 0
let elapsed = 0
const frame = (now: number) => {
if (!last) { last = now; openFadeRafRef.current = requestAnimationFrame(frame); return }
elapsed += Math.min(now - last, 32)
last = now
const t = Math.max(0, Math.min(1, elapsed / DURATION))
const eased = 1 - (1 - t) ** 2 // ease-out quad — softer start than the old cubic, so the page lingers
if (veil) veil.style.opacity = String(Math.max(VEIL_MIN_OPACITY, eased)) // the bar's white rises
if (scrim) scrim.style.opacity = String(eased) // the viewport's white, same curve
if (t < 1) openFadeRafRef.current = requestAnimationFrame(frame)
}
// The id lives in openFadeRafRef (not a local) so the swipe-down reveal can cancel this
// loop the moment a drag starts and own the opacity channel mid-open.
openFadeRafRef.current = requestAnimationFrame(frame)
return () => {
cancelAnimationFrame(openFadeRafRef.current)
if (scrim) scrim.style.opacity = ''
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flightPainted])
useLayoutEffect(() => {
if (containerRef.current) setTargetRect(containerRef.current.getBoundingClientRect())
}, [])
const width = () => containerRef.current?.clientWidth ?? window.innerWidth
// Nav is ready as soon as the track has been measured (targetRect) — NOT once the whole open
// fly-in has finished. Gating on the open animation dropped every arrow pressed in its first
// few hundred ms (longer on a cold/slow load) — presses thrown away. If a press lands
// before the fly-in ends, commit() snaps it complete so the (now visible) track is what slides.
const navReadyRef = useRef(false)
useEffect(() => { navReadyRef.current = !!targetRect }, [targetRect])
// Index-first navigation. A press commits the new item IMMEDIATELY (so arrows always respond
// and naturally interrupt an in-flight slide — the press takes priority), then the incoming
// slide springs home from wherever the track is right now. `x.get() + dir*w` covers every
// start point in one expression: idle (0), a mid-slide interrupt, or a half-finished finger
// drag — so the motion is always continuous, never a snap. No queue, no end-of-slide rebase.
const commit = (dir: 1 | -1, slow = false) => {
if (!navReadyRef.current) return
// At a boundary there's no neighbour to reveal — settle any drag/interrupt back to centre.
if ((dir === 1 && index >= total - 1) || (dir === -1 && index <= 0)) {
animate(x, 0, SPRING)
return
}
// Pressed before the open fly-in finished: reveal the container now so the slide animates
// the real track rather than swiping behind the flying clone.
if (!animDone) flushSync(() => setAnimDone(true))
// The nav is about to slide the track: the (possibly still-overlapping) stationary clone
// must not sit on top of it. Ends the 300ms handoff overlap early.
settleClone()
const fromX = x.get() + dir * width()
const target = dir === 1 ? nextItem : prevItem
x.stop()
flushSync(() => { if (dir === 1) onNext(); else onPrev() })
// A nav DURING the dock's entrance ends the entrance: the incoming caption card is keyed
// per item, and mounting it with .dk-dock-enter still applied replayed the whole rise
// from below the fold — delay and backwards fill included — so the new item showed no
// caption for a beat, then it climbed in late. Batched with setCaptionItem, so the
// remounted card's first render is already class-free.
setDockEntered(true)
if (target) setCaptionItem(target)
// Position the just-committed track synchronously (jump + a direct write, same task as the
// index shift) so the swap lands in one paint with nothing displaced, then spring to centre.
x.jump(fromX)
if (trackRef.current) trackRef.current.style.transform = `translateX(${fromX}px)`
busy.current = true
animate(x, 0, { ...(slow ? SLOW_NAV_SPRING : reduceMotion ? NAV_SPRING_REDUCED : NAV_SPRING), onComplete: () => { busy.current = false } })
}
// Slide the image down and off, then unmount.
const dismiss = () => {
// A SHORT drop, not a ride to the bottom of the screen: the content is fully transparent
// by 280px of travel (dragOpacity), so animating to window.innerHeight just meant watching
// nothing move for most of the duration. Drop a further ~200px from wherever the finger
// let go — always past both the fade end and the page-restore distance (REVEAL_DRAG), so
// the dissolve completes and the page beneath is fully back before the unmount.
const target = Math.max(REVEAL_DRAG + 60, yDrag.get() + 200)
animate(yDrag, target, { duration: 0.16, ease: 'easeIn' })
window.setTimeout(onClose, 150)
}
// Arrow keys animate through the same track as swipe / on-screen arrows.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// Modified chords belong to the browser (Alt+ArrowLeft = Back, Cmd+ArrowLeft = Home) —
// only a bare arrow (or Shift+arrow, the slow-mo debug) navigates. Ported from
// photos-lightbox (2026-08-28 drift audit).
if (e.altKey || e.metaKey || e.ctrlKey) return
if (e.key === 'ArrowRight') { e.preventDefault(); commit(1, e.shiftKey) }
else if (e.key === 'ArrowLeft') { e.preventDefault(); commit(-1, e.shiftKey) }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [index, total])
const onTouchStart = (e: React.TouchEvent) => {
if (busy.current) return
x.stop(); yDrag.stop()
startX.current = e.touches[0].clientX
startY.current = e.touches[0].clientY
axis.current = null
lock.current = null
}
const onTouchMove = (e: React.TouchEvent) => {
if (busy.current) return
const dx = e.touches[0].clientX - startX.current
const dy = e.touches[0].clientY - startY.current
if (axis.current === null && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {
axis.current = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
// A recognized gesture is about to move the media out from under the (possibly
// still-overlapping) stationary clone — end the handoff overlap now.
settleClone()
// Remember WHERE the axis locked. The 8px the finger travelled to trigger the lock is not
// drag — it is the gesture being recognised. Feeding the raw delta in meant the photo
// teleported those 8px the instant the lock fired, and then tracked the finger. That initial
// jump is what breaks the sense that you are holding the thing: the image should start moving
// from exactly where your finger is, and stay under it from then on.
lock.current = { x: dx, y: dy }
}
const lx = lock.current?.x ?? 0
const ly = lock.current?.y ?? 0
if (axis.current === 'x') {
const ox = dx - lx
// Rubber-band when there's no neighbour to reveal.
const resist = (index <= 0 && ox > 0) || (index >= total - 1 && ox < 0)
x.set(resist ? ox * 0.35 : ox)
} else if (axis.current === 'y') {
yDrag.set(Math.max(0, dy - ly)) // downward only, from the point of lock
}
}
const onTouchEnd = () => {
const a = axis.current
axis.current = null
if (busy.current) return
if (a === 'x') {
const dx = x.get()
const threshold = Math.min(width() * 0.25, 90)
if (dx <= -threshold) commit(1)
else if (dx >= threshold) commit(-1)
else animate(x, 0, SPRING)
} else if (a === 'y') {
if (yDrag.get() > CLOSE_DRAG) dismiss()
else animate(yDrag, 0, SPRING)
}
// A tap (no axis lock) falls through to the click handler → close.
}
return (
<m.div
ref={rootRef}
// Fixed, full-viewport via left:0 + width (not right:0). `w-screen` is only the pre-JS fallback;
// the scroll-lock effect sets width to window.innerWidth so the modal also covers a reserved
// scrollbar gutter (100vw stops short of it) — no bare strip on the right, and without touching
// the host's `scrollbar-gutter` (dropping that reflows the page under the modal).
className="dk-scope fixed inset-y-0 left-0 w-screen z-50 flex flex-col select-none overflow-hidden outline-none"
// Programmatically focusable so focus enters the dialog on open (see the inert effect);
// -1 keeps it out of the Tab order itself.
tabIndex={-1}
// Transparent root; the backdrop is an absolute scrim child that FADES IN (below), so the
// background eases in instead of snapping opaque on open. (Safari's toolbar is kept solid
// separately by fading the page content out — see the page-fade effect — since the toolbar
// blurs the document behind this fixed overlay, not the overlay itself.)
// overscrollBehavior: iOS must not rubber-band the page beneath when a drag starts on
// chrome (dock, close button) — the media container already owns touchAction: none.
style={{ color: FG, overscrollBehavior: 'contain' }}
role="dialog"
aria-modal="true"
aria-label={caption ?? 'Photo'}
onClick={onClose}
>
{/* Backdrop scrim — its opacity is eased 0→1 by the page-fade rAF loop (see the effect above),
so the background fades in together with the page fading out rather than snapping opaque.
Kept `absolute`, never `fixed` + negative z — that composited ABOVE the photo in Safari and
hid it. */}
<div ref={scrimRef} className="absolute inset-0 -z-10" style={{ backgroundColor: BG }} />
{/* Arrow keys and the Next/Previous buttons swap the photo without any announcement (a
changed aria-label on the dialog is never re-read) — this quiet status line is what
a screen reader hears on each navigation. */}
<div role="status" className="sr-only">
{`Photo ${index + 1} of ${total}${caption ? `: ${caption}` : ''}`}
</div>
{/* Inner layer carries the swipe-down-to-dismiss transform. */}
{/* The swipe used to translate THIS whole layer — chrome included. The drag now lives
on the media area alone (and the rail fades in place): the close button and arrows
hold still while the photo is pulled away (Dave's call, 2026-07-24). */}
<div className="relative flex flex-1 flex-col min-h-0">
{/* Close — sits above the image; the surrounding layer passes clicks through. */}
{/* Chrome waits for the clone handoff (animDone): during the fly-in the photo is
an animated clone on its own compositing layer, and backdrop-filter cannot
sample it — glass shown mid-flight blurs nothing and the blur snaps in at
landing, in every engine. Chrome that appears AT the handoff wears a working
blur from its first frame. */}
<style>{`
.dk-dock-hold { opacity: 0 !important; animation: none !important; }
.dk-dock-fade { animation: dk-dock-fade 0.35s ease-out both; }
@keyframes dk-dock-fade { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .dk-dock-fade { animation-duration: 0.01s; } }
`}</style>
<m.div
className="absolute inset-0 pointer-events-none z-30"
initial={{ opacity: 0 }}
animate={{ opacity: animDone ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
{/* Fades with the drag but does NOT move. It was the last opaque thing on the modal
at unmount, and iOS 26's bottom-bar backdrop cache held a snapshot of it (plus the
modal's white field) for ~a second after a swipe dismiss — a ghost close button.
Fully-faded chrome means any stale snapshot is of nothing. The outer overlay owns
the entrance opacity, so the drag binding needs its own layer. */}
<m.div className="absolute inset-0" style={{ opacity: dragOpacity }}>
<button
onClick={(e) => { e.stopPropagation(); onClose() }}
aria-label="Close"
// touchAction: taps only — a drag starting on the button must not scroll the page beneath.
style={{ touchAction: 'manipulation' }}
data-on-photo={chromeOnPhoto.close || undefined}
className="dk-chrome-btn pointer-events-auto absolute top-3 right-[calc(16px+var(--dk-sb-gutter,0px))] flex h-11 w-11 cursor-pointer items-center justify-center rounded-full text-[var(--dk-fg)] transition-colors hover:bg-[var(--dk-surface)] active:bg-[var(--dk-surface)]"
>
<IconClose />
</button>
</m.div>
</m.div>
{/* Image area — full width so the slide track can carry an asset fully off-screen; the 16px
side margins live on each slide's inner box (see Slide), not here, so they never clip the
animation. No vertical padding: the photo owns the full lightbox height. */}
{/* max-sm:pb-[var(--dk-dock-h)]: on mobile the chrome never hides and a width-constrained
photo doesn't reach the viewport bottom, so without this the glass card straddles
the photo's bottom edge — half on photo, half on backdrop. Reserving the dock's
measured height (ResizeObserver above) centers the photo in the space above it.
Desktop keeps true full-bleed: the photo fills the height and the dock overlays. */}
<m.div className="flex-1 min-h-0 flex max-sm:pb-[var(--dk-dock-h,0px)]" style={{ y: yDrag, opacity: dragOpacity }}>
<div
ref={containerRef}
className="flex-1 min-w-0 relative overflow-hidden"
style={{ opacity: containerVisible ? 1 : 0, touchAction: 'none' }}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
<m.div ref={trackRef} className="absolute inset-0" style={{ x }}>
{/* Neighbours do not MOUNT until the open has landed. They are off-screen the whole time
(translateX ±100%), but mounting them meant React committing two extra <img>/<video>
trees on the click frame — and that commit is what delayed the spring's first frame.
They were already deferring their hi-res layer for the same reason; this defers the
whole slide. Nav still works: commit() flushSync's animDone before it slides. */}
{prevItem && animDone && <Slide key={prevItem.src} item={prevItem} offset="-100%" hiRes getHiResSrc={getHiResSrc} showOutline={showOutline} />}
<Slide
key={item.src}
item={item}
offset="0%"
priority
// The hi-res layer waits for the landing, exactly as the neighbours' does.
//
// The clicked slide used to mount TWO images on the click frame: the base (the
// origin's already-decoded file — instant, from cache) and a FRESH large fetch on
// top of it. That second fetch and its decode landed right on the spring's opening
// frames. Measured in Safari at 60fps: the first two frames of the open took 32ms and
// 37ms — two missed vsyncs — and every frame after them was a clean 16-17ms. That is
// the "jitter or two" when opening an image.
//
// Nothing is lost by waiting: the base layer is the exact file already on screen, so
// the flight and the landing look identical either way; the sharp layer just fades in
// once the animation is out of the way, which is already how every other slide behaves.
hiRes={animDone}
getHiResSrc={getHiResSrc}
showOutline={showOutline}
armed={animDone}
getCloneTime={getCloneTime}
onLoad={() => setImageLoaded(true)}
/>
{nextItem && animDone && <Slide key={nextItem.src} item={nextItem} offset="100%" hiRes getHiResSrc={getHiResSrc} showOutline={showOutline} />}
</m.div>
</div>
</m.div>
{/* Corner dock — the photo fills the entire lightbox; caption + EXIF ride a
dark-material glass card centered at the bottom, flanked by the prev/next
buttons (theme ink, surface hover), all floating over the photo. Dark material
(black-tinted blur, boosted saturation) is the HIG direction for light text
over photos — readable on any image, no scrim. Off the photo (and always on
mobile, where the card sits on the backdrop below the photo) the material
comes off and the text speaks in theme ink — see .dk-caption-card.
The dock fades with the swipe-down drag via dragOpacity; while that ancestor
opacity dips below 1 the card's blur goes flat (backdrop root) — accepted,
it's a dismissal gesture over a moving photo.
Entrance DURING the fly-in: arrows and caption card rise in lockstep from
below the viewport edge alongside the photo's flight. It is a pure CSS
keyframes animation (.dk-dock-enter) applied to the dock's CHILDREN, never
this dock: motion re-applies its cached transform/will-change to this element
on every re-render, and any transform on an ANCESTOR of the glass card makes
it the backdrop root — the card's blur samples a transparent subtree instead
of the photo (flat tint). The card's OWN transform keeps its backdrop
sampling live, so the glass blurs for the whole ride up. dockRiseClass comes
off once the entrance ends — per-item remounts of the keyed card must not
replay it. motion owns ONLY the dragOpacity binding here. Runs once per open. */}
<m.div
ref={dockRef}
onClick={(e) => e.stopPropagation()}
// Slow-mo stretches the rise via the vars the children's animation reads;
// the first animationend flips dockEntered (class removal) and re-measures
// the caption geometry with everything at rest.
// touchAction manipulation: taps on the dock (arrows, caption card) stay taps — a drag
// starting here must not rubber-band the page beneath the modal on iOS.
style={{ opacity: dragOpacity, touchAction: 'manipulation', ...(slowMo ? ({ '--dk-dock-rise-dur': '3s', '--dk-dock-rise-delay': '0.6s' } as React.CSSProperties) : null) }}
onAnimationEnd={(e) => {
if ((e as React.AnimationEvent).animationName !== 'dk-dock-fade') return
setDockEntered(true)
measureCapRef.current()
}}
className="dk-rail-foot absolute inset-x-0 bottom-0 z-[25]"
>
{/* items-end: a long caption grows the card UPWARD while the arrows stay anchored
to the bottom edge instead of riding up with the row's centerline. On mobile the
caption takes the full dock width on its own line ABOVE the arrows (order-first +
basis-full wraps it); justify-between then spreads the arrows to the edges. */}
{/* 16px side padding at every size — the photo slides carry 16px inner margins, so
card edges and arrow buttons sit on the same vertical lines as the photo. */}
<div className="flex max-sm:flex-wrap items-end justify-between gap-3 pl-4 pr-[calc(16px+var(--dk-sb-gutter,0px))] pb-1">
{index > 0 ? (
<button
ref={prevBtnRef}
onClick={(e) => { e.stopPropagation(); commit(-1, e.shiftKey) }}
aria-label="Previous photo"
data-on-photo={chromeOnPhoto.prev || undefined}
className={`${dockRiseClass}dk-chrome-btn flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-[var(--dk-fg)] transition-colors hover:bg-[var(--dk-surface)] active:bg-[var(--dk-surface)]`}
>
<IconArrow dir="left" />
</button>
) : (
<span className={`${dockRiseClass}h-11 w-11 shrink-0`} />
)}
<div className="flex min-w-0 flex-1 justify-center max-sm:order-first max-sm:basis-full">
{hasCaptions && (capCaption || capExif) && (
<div
key={captionItem.src}
ref={capCardRef}
data-on-photo={capOnPhoto ? '' : undefined}
className={`${dockRiseClass}dk-caption-card max-w-full rounded-xl px-3.5 py-2.5 text-center`}
>
{capCaption && (
<p className="font-[family-name:var(--dk-font-sans)] text-[14px] leading-[1.35]" style={{ color: 'var(--dk-cap-fg)' }}>{capCaption}</p>
)}
{capExif && (
// Wraps rather than truncates (mobile EXIF must stay whole); the roomier
// leading keeps the lines readable when they stack.
<p className={`${capCaption ? 'mt-1 ' : ''}font-[family-name:var(--dk-font-mono)] text-[11px] leading-[1.5]`} style={{ color: 'var(--dk-cap-muted)' }}>
{capExif}
</p>
)}
</div>
)}
</div>
{index < total - 1 ? (
<button
ref={nextBtnRef}
onClick={(e) => { e.stopPropagation(); commit(1, e.shiftKey) }}
aria-label="Next photo"
data-on-photo={chromeOnPhoto.next || undefined}
className={`${dockRiseClass}dk-chrome-btn flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-full text-[var(--dk-fg)] transition-colors hover:bg-[var(--dk-surface)] active:bg-[var(--dk-surface)]`}
>
<IconArrow dir="right" />
</button>
) : (
<span className={`${dockRiseClass}h-11 w-11 shrink-0`} />
)}
</div>
</m.div>
{cloneTarget && originRect && (
<m.div
className="absolute overflow-hidden pointer-events-none"
// Sits at the photo's final rect; a transform (translate + uniform scale from the
// thumbnail) does the flight, so only the compositor works — no per-frame layout.
//
// The clone STAYS MOUNTED at opacity 0 after the handoff instead of unmounting.
// Unmounting it forced Safari to tear down and re-composite the layer tree at the
// exact moment the modal video's fresh layer was still being promoted — the transient
// "not fully opaque" washout that read as a white flash at the end of the open. An
// opacity flip destroys nothing; the whole subtree simply leaves with the modal.
style={{
zIndex: 20,
left: cloneTarget.left,
top: cloneTarget.top,
width: cloneTarget.width,
height: cloneTarget.height,
transformOrigin: '0 0',
opacity: cloneGone ? 0 : 1,
}}
initial={{
x: originRect.left - cloneTarget.left,
y: originRect.top - cloneTarget.top,
scaleX: originRect.width / cloneTarget.width,
scaleY: originRect.height / cloneTarget.height,
}}
animate={fly ? { x: 0, y: 0, scaleX: 1, scaleY: 1 } : {
x: originRect.left - cloneTarget.left,
y: originRect.top - cloneTarget.top,
scaleX: originRect.width / cloneTarget.width,
scaleY: originRect.height / cloneTarget.height,
}}
transition={openSpring}
// Guarded on `fly`: before the gate opens, animate === initial, and motion reports that
// no-op as "complete". Acting on it would flip animDone, hide the clone, and skip the
// flight entirely.
onAnimationComplete={() => { if (fly) flushSync(() => setAnimDone(true)) }}
>
{item.videoSrc ? (
FLY_LIVE_VIDEO ? (
// The clicked clip KEEPS PLAYING while it flies: a muted clone resumes from the
// origin's exact frame (videoTime). But a fresh <video> paints NOTHING until it has
// metadata AND has finished seeking to that frame — and an unpainted <video> is
// TRANSPARENT (measured in Safari), so the clone was a HOLE that showed the scrim
// straight through it. Safari also drops `poster` as soon as the video starts
// loading, so the poster did not cover the gap the way it does in Chrome.
//
// Hence the base layer below: the frozen frame ALWAYS paints, and the live <video>
// sits on top of it and covers it the moment it has a real frame. Playback is
// preserved; the hole is not possible.
//
// Long clips are the ones that show the hole: the clone may seek tens of seconds in,
// which Safari takes real time to do. Short loops seek almost instantly.
<>
<FlightStill
item={item}
onPainted={fireFlightPainted}
// Hidden while the live video flies (its click frame goes stale within
// frames), then resurfaced with FRESH pixels for the handoff window.
hidden={cloneVideoShown && !stillResurfaced}
repaintRef={stillRepaintRef}
/>
<FlightVideo item={item} videoRef={cloneVideoRef} onShown={() => setCloneVideoShown(true)} />
</>
) : item.flightPoster ? (
// FLY_LIVE_VIDEO off: fly the FROZEN frame instead of a second live <video>.
// Same pixels (flightPoster is a canvas snapshot of the exact clicked frame), but
// no video element is instantiated inside the animating layer. The modal player still
// lands on the right frame — it starts from item.videoTime regardless — so the clip
// simply does not advance during the flight.
<img
src={item.flightPoster}
className="absolute inset-0 h-full w-full object-contain"
alt=""
aria-hidden
onLoad={fireFlightPainted}
/>
) : null // clip had no decodable frame at click (never loaded) — fly nothing; the
// origin simply stays put until the modal player takes over.
) : (
<img
// flightSrc = the origin's already-decoded file, served verbatim — the clone
// paints on frame 1 of the flight even on a cold cache. (A fresh differently-sized
// fetch here often finished loading AFTER the spring, so no fly-in was ever seen.)
src={item.flightSrc || item.src}
className="absolute inset-0 h-full w-full object-contain"
alt=""
aria-hidden
onLoad={fireFlightPainted}
/>
)}
</m.div>
)}
</div>
</m.div>
)
}
/* ------------------------------------------------------------------------------------------------
* useDKLightbox — the controller both openers (grid, carousel) drive the lightbox through.
* Owns: selection state, the click-time capture (origin rect, decoded src, video frame snapshot),
* keyboard handling (Escape, and the modality tracking that decides whether close restores a
* focus ring), origin hide/restore, neighbour prefetch, deep links, and the portal itself.
* ---------------------------------------------------------------------------------------------- */
// Returns focus to `el` after the modal closes. `withRing` should be true only when the session
// showed real keyboard navigation (a Tab press, or a keyboard-activated open): those users need
// the :focus-visible ring to see where they landed. A mouse user who pressed Escape just gets
// their focus position back with no ring — the browser's own heuristic counts Escape as
// "keyboard" and would flash the ring at them, which is exactly the over-trigger this avoids.
function restoreFocus(el: HTMLElement, withRing: boolean) {
el.focus({ preventScroll: true })
el.scrollIntoView({ block: 'nearest' })
if (withRing) return
// Suppress the ring for this landing only — ALL THREE pieces of any global :focus-visible
// recipe the host site may have: an outline, a box-shadow halo, AND a border-radius.
// (An Escape press makes the browser treat the subsequent programmatic focus as
// :focus-visible, so such a rule fires even for a mouse session; suppressing only
// outline+shadow left the radius rounding a thumbnail's corners — the fix that landed in
// the site's lib/restore-focus and is mirrored here, 2026-08-28 drift audit.) Inline
// styles win over the rule; lifted the moment focus moves on or the keyboard comes into play.
const prevOutline = el.style.outline
const prevShadow = el.style.boxShadow
const prevRadius = el.style.borderRadius
const lift = () => {
el.style.outline = prevOutline
el.style.boxShadow = prevShadow
el.style.borderRadius = prevRadius
el.removeEventListener('blur', lift)
window.removeEventListener('keydown', lift)
}
el.style.outline = 'none'
el.style.boxShadow = 'none'
el.style.borderRadius = '0'
el.addEventListener('blur', lift, { once: true })
window.addEventListener('keydown', lift, { once: true })
}
export interface DKLightboxOptions {
/** A sharper large variant to fade in over the base image (and to prefetch around the current
* one). Return item.src (or nothing) to opt an item out. */
getHiResSrc?: (item: DKMediaItem) => string
/** Reserve the caption/exif rail. Default: on when any item carries a caption or EXIF. */
hasCaptions?: boolean
/** The 1px photo edge outline in the modal. Default true (photographs). */
showOutline?: boolean
/** Open from `?photo=<index>` on mount (and strip the param), for shareable deep links. */
deepLink?: boolean
/** The on-page element for an item, used to hand focus back on close (and as the deep-link
* zoom origin). Wire this to a ref registry of your thumbnails. */
getOriginEl?: (index: number) => HTMLElement | null
/** Fired as the modal closes, with the index the viewer was on — e.g. so a carousel can show
* the slide they navigated to before focus lands on it. */
onClose?: (index: number) => void
}
export function useDKLightbox(items: DKMediaItem[], options: DKLightboxOptions = {}) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [originRect, setOriginRect] = useState<DOMRect | null>(null)
const [slowMo, setSlowMo] = useState(false) // shift-click: ~3s slow-motion open for inspection
const itemsRef = useRef(items)
useEffect(() => { itemsRef.current = items }, [items])
const optionsRef = useRef(options)
useEffect(() => { optionsRef.current = options })
// The clicked item's runtime capture (decoded src / video frame), keyed to its index.
// State, not a ref: the portal render below reads it, and render must not read refs.
const [capture, setCapture] = useState<{ index: number; data: Partial<DKLightboxItem> } | null>(null)
const prefetchedHiRes = useRef<Set<string>>(new Set())
// Videos elsewhere in the opener's scope, paused while the overlay is open — otherwise they
// keep playing behind the fading-in backdrop and flash as "remnants". Resumed on close.
const pausedRef = useRef<HTMLVideoElement[]>([])
// The clicked element — hidden while open so it doesn't sit under the fly-in clone (the clone
// IS that asset moving into the modal). visibility keeps its layout slot, so nothing reflows.
const originElRef = useRef<HTMLElement | null>(null)
// The page's scroll position at open. Close restores it EXACTLY: traversing the modal must not
// move the reader — restoreFocus's scrollIntoView on the closed-on element was relocating the
// page after a few swipes.
const scrollPosRef = useRef<{ x: number; y: number } | null>(null)
// The last item shown, surviving close's setSelectedIndex(null) — close hands focus back to
// ITS element (not the entry one; the viewer may have arrowed far from where they came in).
const latestIndexRef = useRef<number | null>(null)
useEffect(() => {
if (selectedIndex !== null) latestIndexRef.current = selectedIndex
}, [selectedIndex])
// Did this session show REAL keyboard navigation (keyboard-activated open, or a Tab while
// open)? Only then does the restored thumbnail show its focus ring. Escape/arrows don't
// count — mouse users press those too, and the browser's own focus-visible heuristic
// flashing a ring after a mouse session + Escape is exactly the misfire this replaces.
const keyboardModeRef = useRef(false)
useEffect(() => {
if (selectedIndex === null) return
const onTab = (e: KeyboardEvent) => {
[truncated — showing 100000 of 109716 characters]registry/dk-media-viewer/dk-carousel.tsx
'use client'
/* DKCarousel — a crossfading slideshow over the same items the grid takes.
*
* <DKCarousel items={items} ratio="3 / 2" />
*
* Images are stacked; the outgoing slide animates opacity + a small blur (a "blur-dissolve"),
* both GPU-composited (safe for photos; the subpixel-AA caveat that bars opacity-animating TEXT
* does not apply to images). The frame carries a fixed aspect-ratio so it reserves its space and
* never shifts layout. Auto-advance stops for `prefers-reduced-motion` and while the tab is
* hidden. Clicking a slide opens the shared lightbox over the whole set — navigable with the
* same arrows, captions and EXIF included.
*
* The fade is one-directional to avoid the crossfade "dip": the incoming slide snaps fully
* opaque UNDERNEATH, and only the OUTGOING slide fades out on top of it. If both faded at once,
* the midpoint (both ~50% transparent) would let the page show through and read as a flash. So
* the outgoing frame carries the transition and the higher z-index; the incoming one just sits
* there solid, revealed as the old one dissolves away. */
import { useEffect, useRef, useState } from 'react'
import type { DKMediaItem } from './types'
import { useDKLightbox } from './dk-lightbox'
export interface DKCarouselProps {
items: DKMediaItem[]
/** The frame's CSS aspect-ratio, e.g. "3 / 2", "16 / 9". */
ratio?: string
/** Auto-advance dwell per slide, in ms. */
interval?: number
/** Extra classes on the outer wrapper. */
className?: string
/** A sharper large variant for the lightbox — see DKMediaViewer. */
getHiResSrc?: (item: DKMediaItem) => string
/** The 1px photo edge outline in the lightbox. Default off here — carousels often carry
* screenshots and full-bleed art where it reads as a stray border. */
showOutline?: boolean
}
export function DKCarousel({
items,
ratio = '3 / 2',
interval = 5000,
className,
getHiResSrc,
showOutline = false,
}: DKCarouselProps) {
// active + the slide that should fade OUT, advanced atomically: every jump records the
// outgoing index in the same state update, so the render that shows the new slide still
// knows which one is fading — no ref read during render, no post-commit effect.
const [indices, setIndices] = useState({ active: 0, prev: 0 })
const { active, prev } = indices
const setActive = (next: number | ((cur: number) => number)) =>
setIndices(({ active }) => ({ active: typeof next === 'function' ? next(active) : next, prev: active }))
const slideEls = useRef<Map<number, HTMLButtonElement>>(new Map())
const { open, isOpen, lightbox } = useDKLightbox(items, {
getHiResSrc,
showOutline,
getOriginEl: (i) => slideEls.current.get(i) ?? null,
// Land the carousel on the slide the viewer navigated to in the lightbox, so the close
// fly-back and the focus restore both target the slide that is actually visible.
onClose: (i) => setActive(i),
})
// The effect depends on `active`, so it re-arms the timeout every advance — which also means
// a manual jump (a dot click that sets `active`) resets the dwell, keeping the progress pill
// in sync with the timer. No autoplay for reduced-motion users; the tab-hidden pause avoids
// a burst of queued advances landing at once on return. Paused while the lightbox is open —
// the viewer is looking at THIS set enlarged; the deck shouldn't shuffle under them.
useEffect(() => {
if (items.length < 2 || isOpen) return
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
let timer: ReturnType<typeof setTimeout>
const schedule = () => {
timer = setTimeout(() => setActive((i) => (i + 1) % items.length), interval)
}
const onVisibility = () => {
clearTimeout(timer)
if (!document.hidden) schedule()
}
if (!document.hidden) schedule()
document.addEventListener('visibilitychange', onVisibility)
return () => {
clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [active, items.length, interval, isOpen])
if (items.length === 0) return null
return (
<div data-dk-scope className={`dk-scope w-full${className ? ` ${className}` : ''}`}>
<style>{`
/* Animate transform, not width: a width animation relayouts the pill every frame (main
thread) and reads as stepping; scaleX runs on the compositor and stays smooth. */
@keyframes dk-ss-progress { from { transform: scaleX(0) } to { transform: scaleX(1) } }
.dk-ss-fill { width: 100%; transform-origin: left; }
@media (prefers-reduced-motion: no-preference) {
.dk-ss-fill { transform: scaleX(0); animation: dk-ss-progress var(--dk-ss-dur, 5000ms) linear forwards; }
}
`}</style>
<div className="relative w-full overflow-hidden" style={{ aspectRatio: ratio }}>
{items.map((item, i) => {
const isActive = i === active
// Only the outgoing slide animates (fades to 0) and sits on top; the incoming slide
// snaps to full opacity beneath it. `prev !== active` guards the first paint, where
// there is no previous slide.
const isPrev = i === prev && prev !== active
return (
// Each slide opens the shared lightbox — and because every slide is registered, the
// lightbox gallery is the whole carousel, navigable with the same arrows. Only the
// active slide is interactive: the stacked inactive slides sit at pointer-events:none
// (and out of the tab order) so a click can't land on the top-of-stack image instead
// of the visible one. The crossfade rides the button's opacity.
<button
key={item.src}
type="button"
ref={(el) => {
if (el) slideEls.current.set(i, el)
else slideEls.current.delete(i)
}}
onClick={(e) => open(i, e.currentTarget, e)}
aria-label={item.alt ? `Enlarge: ${item.alt}` : 'View enlarged'}
aria-hidden={!isActive}
tabIndex={isActive ? 0 : -1}
className={`absolute inset-0 block cursor-zoom-in ease-out${
isPrev ? ' transition-[opacity,filter] duration-700 motion-reduce:transition-none' : ''
}`}
style={{
opacity: isActive ? 1 : 0,
// Blur-dissolve: the outgoing slide softens as it leaves, so the departure reads as
// a cinematic dissolve rather than a flat opacity ramp. The incoming slide stays
// sharp and solid beneath (see the one-way fade note above), so there's no dip.
filter: isPrev ? 'blur(6px)' : 'none',
// Outgoing on top (it does the fading); incoming just beneath, solid; rest behind.
zIndex: isPrev ? 20 : isActive ? 10 : 0,
pointerEvents: isActive ? 'auto' : 'none',
}}
>
<img
src={item.src}
alt={item.alt ?? ''}
className="absolute inset-0 h-full w-full object-cover"
decoding="async"
draggable={false}
/>
</button>
)
})}
{items.length > 1 && (
// bottom-0: dots centred in a 24px hit area (8px padding) land the dot 8px above the base.
// z-30 keeps the dots above the slides, which carry z-index (10/20) for the one-way fade.
<div className="absolute inset-x-0 bottom-0 z-30 flex items-center justify-center gap-1">
{items.map((item, i) => {
const isActive = i === active
return (
<button
key={item.src}
type="button"
aria-label={`Show ${item.alt ?? `slide ${i + 1}`}`}
aria-current={isActive}
onClick={() => setActive(i)}
// The dot stays small, but the button is a 24px-minimum hit target so it is
// easy to click, with a pointer cursor and a hover brighten so it reads as one.
// The ::before extension (not min-w) supplies the extra clickable width, so the
// dots' visual spacing is untouched while every target clears 24x24. (Ported
// from the site slideshow, 2026-08-28 drift audit.)
className="group/dot relative flex h-6 cursor-pointer items-center px-0.5 before:absolute before:inset-y-0 before:-inset-x-1.5"
>
<span
className="relative block h-2 overflow-hidden rounded-full drop-shadow-[0_0_2px_rgba(0,0,0,0.6)] transition-[width,filter] duration-300 ease-out group-hover/dot:brightness-150 motion-reduce:transition-none"
style={{
width: isActive ? 30 : 8,
backgroundColor: isActive ? 'rgba(255,255,255,0.4)' : 'rgba(255,255,255,0.55)',
}}
>
{/* The active pill's fill sweeps over the dwell time. Keyed by `active` so
React remounts it each advance, restarting the CSS animation from 0. */}
{isActive && (
<span
key={active}
className="dk-ss-fill absolute inset-y-0 left-0 rounded-full bg-white"
style={{ ['--dk-ss-dur' as string]: `${interval}ms` }}
/>
)}
</span>
</button>
)
})}
</div>
)}
</div>
{items.some((item) => item.caption) && (
// The live region stays mounted across advances (an element that appears with its
// text isn't announced), so auto-advance caption swaps reach screen readers.
// (Ported from the site slideshow, 2026-08-28 drift audit.)
<p aria-live="polite" className="mt-3 text-center font-[family-name:var(--dk-font-sans)] text-[0.95rem] leading-snug text-[var(--dk-muted)]">
{items[active].caption}
</p>
)}
{lightbox}
</div>
)
}
registry/dk-media-viewer/types.ts
/* DKMediaViewer — the data contract.
*
* One item = one photo or one video. Hand-write the array, generate it with
* `npx @diklein/dkmediaviewer scan ./public/photos`, or map it from your CMS — the viewer
* only ever sees this shape. Everything beyond `src` is optional and degrades
* cleanly: no exif → no spec line, no caption → no caption well, no dimensions
* → the layout measures the rendered element instead. */
export interface DKExif {
make?: string | null
model?: string | null
/** Shutter as the photographer says it: "1/80", "0.5", "30". */
exposureTime?: string | null
/** Aperture without the ƒ: "1.6", "8". */
aperture?: string | null
/** Focal length in mm, without the unit: "35", "23". */
focalLength?: string | null
iso?: number | null
}
export interface DKMediaItem {
/** The image URL — or, for a video, its poster frame. */
src: string
/** Marks the item as a video and gives the clip to play (mp4/webm URL). */
videoSrc?: string
alt?: string
/** Shown in the lightbox caption well (and read to assistive tech when alt is absent). */
caption?: string
/** Intrinsic pixels. Optional, but supplying them avoids layout shift in the grid. */
width?: number
height?: number
/** Average/dominant color as #rrggbb — used for the blur-up placeholder. */
color?: string
/** Camera settings for the mono spec line under the caption. */
exif?: DKExif
}
function toTitleCase(str: string): string {
return str.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
/** EXIF `Make` is often the full legal name ("NIKON CORPORATION", "CASIO COMPUTER CO.,LTD.").
* Strip the legal-entity tail(s) so the spec line reads like a photographer, not a filing:
* "Nikon D610", never "Nikon Corporation D610". */
function cleanMake(raw: string): string {
let make = toTitleCase(raw)
const legal = /[\s,]*(Corporation|Corp|Company|Co|Ltd|Inc|Gmbh|K\.K)\.?,?$/i
while (legal.test(make)) make = make.replace(legal, '')
return make
}
/** "Ricoh GR IIIx · 26.1mm · ƒ/2.8 · 1/500s · ISO 200" — omits whatever is missing.
* Drops a leading brand word from the model that just repeats the make (make "Ricoh",
* model "RICOH GR IIIx" → "GR IIIx"), so the camera never reads doubled. */
export function formatExif(exif?: DKExif | null): string {
if (!exif) return ''
const parts: string[] = []
const make = exif.make ? cleanMake(exif.make) : null
let model = exif.model ?? null
if (make && model) {
model = model.replace(new RegExp(`^${make.split(/[\s,]/)[0]}\\s+`, 'i'), '') || null
}
if (make || model) parts.push([make, model].filter(Boolean).join(' '))
if (exif.focalLength) parts.push(`${exif.focalLength}mm`)
if (exif.aperture) parts.push(`ƒ/${exif.aperture}`)
if (exif.exposureTime) parts.push(`${exif.exposureTime}s`)
if (exif.iso) parts.push(`ISO ${exif.iso}`)
return parts.join(' · ')
}
registry/dk-media-viewer/dk-media-viewer.css
/* DKMediaViewer — theme tokens + the handful of rules Tailwind utilities can't express.
*
* Every color routes through a --dk-* variable so theming is one override away:
*
* .dk-scope { --dk-accent: rebeccapurple; }
*
* Dark mode follows the shadcn convention (a `.dark` class on <html>) and falls back to
* prefers-color-scheme when no theme class is in play. Fonts default to the system stacks;
* point --dk-font-sans / --dk-font-mono at your own faces to match your site. */
.dk-scope {
--dk-bg: #ffffff;
--dk-fg: #1a1a1a;
--dk-muted: #6b6b6b;
--dk-surface: rgb(0 0 0 / 0.05);
--dk-accent: #e03131;
--dk-outline: rgb(0 0 0 / 0.1);
--dk-font-sans: ui-sans-serif, system-ui, sans-serif;
--dk-font-mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, monospace;
}
.dark .dk-scope {
--dk-bg: #101216;
--dk-fg: #ededed;
--dk-muted: #9a9a9a;
--dk-surface: rgb(255 255 255 / 0.08);
--dk-outline: rgb(255 255 255 / 0.1);
}
/* No theme class anywhere up the tree? Track the OS. (:where() keeps specificity at zero so
the .dark rule above always wins when present.) */
@media (prefers-color-scheme: dark) {
:where(html:not(.light) .dk-scope) {
--dk-bg: #101216;
--dk-fg: #ededed;
--dk-muted: #9a9a9a;
--dk-surface: rgb(255 255 255 / 0.08);
--dk-outline: rgb(255 255 255 / 0.1);
}
}
/* A hairline edge INSIDE the photo (outline, not border, so it never adds layout size) —
keeps near-white photos from dissolving into a light page, and near-black ones into dark. */
.dk-img-outline {
outline: 1px solid var(--dk-outline);
outline-offset: -1px;
}
/* Loading placeholder: a quiet tint with a translucent sweep. */
.dk-shimmer {
position: relative;
overflow: hidden;
background: color-mix(in oklab, var(--dk-fg) 4%, transparent);
}
.dk-shimmer::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--dk-fg) 6%, transparent), transparent);
animation: dk-shimmer-sweep 1.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.dk-shimmer::after {
animation: none;
}
}
@keyframes dk-shimmer-sweep {
100% {
transform: translateX(100%);
}
}
/* Grid perf: skip rendering off-screen rows until they approach the viewport. */
.dk-cv-auto {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
}
/* Lightbox bottom dock — 8px bottom padding on mobile (never less than the device's
safe-area inset), a touch more on desktop. */
.dk-rail-foot {
padding-bottom: max(0.5rem, env(safe-area-inset-bottom));
}
@media (min-width: 56.25rem) {
.dk-rail-foot {
padding-bottom: 0.75rem;
}
}
/* Dock entrance. Pure CSS on purpose: a motion-managed transform gets re-applied on
every re-render, and any transform/will-change on an ANCESTOR of the glass card
makes that ancestor the backdrop root — the card's blur flat-tints (see the card
recipe below). The class therefore goes on the dock's CHILDREN (arrows + the card
itself), rising in lockstep: an element's OWN transform never breaks its backdrop
sampling, so the glass blurs LIVE for the whole ride. All children travel the same
fixed distance (the dock's published --dk-dock-h, plus some slack) so they move as
one unit; backwards fill holds them below the fold through the delay and evaporates
on the last frame. The lightbox removes the class once the entrance ends, so
per-item remounts of the caption card never replay it. Slow-mo stretches it via
--dk-dock-rise-dur/-delay on the dock.
The linear() easing is the house spring itself — x(t) for stiffness 620, damping 36,
mass 0.7 sampled over 500ms — so the rise shares the fly-in's physics, decel tail
and all. The cubic-bezier declaration is the fallback for browsers without linear(). */
@keyframes dk-dock-rise {
from { transform: translateY(calc(var(--dk-dock-h, 96px) * 1.15)); }
to { transform: translateY(0); }
}
.dk-dock-enter {
animation: dk-dock-rise var(--dk-dock-rise-dur, 240ms) cubic-bezier(0.34, 1.3, 0.64, 1) var(--dk-dock-rise-delay, 80ms) backwards;
animation: dk-dock-rise var(--dk-dock-rise-dur, 500ms) linear(0, 0.1346, 0.3785, 0.6041, 0.7719, 0.8815, 0.9461, 0.9806, 0.9968, 1.0031, 1.0046, 1.004, 1.0029, 1.0019, 1.0011, 1.0006, 1.0003, 1.0001, 1, 1, 1, 1, 1, 1, 1) var(--dk-dock-rise-delay, 80ms) backwards;
}
/* Lightbox caption card. Desktop ON the photo: HIG thin-material-dark (black-tinted
blur, boosted saturation, hairline ring) because the photo fills the height and the
card sits over it. CONSTRAINT there: no ancestor of the card may carry opacity < 1
or a transform (it becomes the backdrop root and the blur flat-tints). Off the photo
— and always on mobile, where the photo rarely reaches the dock — the material comes
off and the text speaks in theme ink. --dk-cap-fg/--dk-cap-muted flip with the
material; the [data-on-photo] attribute is set by the lightbox's geometry check. */
.dk-caption-card {
--dk-cap-fg: var(--dk-fg);
--dk-cap-muted: var(--dk-muted);
}
@media (min-width: 640px) {
.dk-caption-card[data-on-photo] {
--dk-cap-fg: rgba(245, 245, 245, 0.95);
--dk-cap-muted: rgba(245, 245, 245, 0.58);
background: rgba(0, 0, 0, 0.42);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.15);
}
/* The chrome buttons (close / prev / next) wear the caption's glass when THEIR
geometry says they're over the photo — ink buttons vanish on dark photos. Same
material, circle-sized; the per-button data-on-photo verdict is per-axis. */
.dk-chrome-btn[data-on-photo] {
color: rgba(245, 245, 245, 0.95);
background: rgba(0, 0, 0, 0.42);
backdrop-filter: blur(20px) saturate(1.8);
-webkit-backdrop-filter: blur(20px) saturate(1.8);
box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.15);
}
.dk-chrome-btn[data-on-photo]:hover,
.dk-chrome-btn[data-on-photo]:active {
background: rgba(0, 0, 0, 0.58);
}
}