<div class="pz-viewer">
<div class="pz-frame" aria-label="Pinch or double tap to zoom">
<img
class="pz-photo"
src="/photos/japan/5.jpg"
alt="A street in Tokyo at night"
draggable="false"
/>
</div>
<div class="pz-footer">
<div class="pz-caption">
<span class="pz-title">Shinjuku</span>
<span class="pz-meta">11:42 pm</span>
</div>
<div class="pz-status">
<span class="pz-hint">Pinch or double tap</span>
<span class="pz-zoom" aria-live="polite">1.0×</span>
</div>
</div>
</div>
<script type="module">
import {
animate,
cancelFrame,
drag,
frame,
mapValue,
motionValue,
pinch,
press,
styleEffect,
} from "motion"
const viewer = document.querySelector(".pz-frame")
const photo = document.querySelector(".pz-photo")
const zoomLabel = document.querySelector(".pz-zoom")
const ui = { type: "spring", stiffness: 305, damping: 33 }
/**
* pinch(), drag() and animate() all write to the photo's scale, x and
* y. Binding our own motion values with styleEffect means we can read
* and animate the same values they use.
*/
const scale = motionValue(1)
const x = motionValue(0)
const y = motionValue(0)
styleEffect(photo, { scale, x, y })
styleEffect(document.querySelector(".pz-hint"), {
opacity: mapValue(scale, [1, 1.15], [1, 0]),
})
styleEffect(zoomLabel, {
opacity: mapValue(scale, [1, 1.15], [0, 1]),
})
scale.on("change", (latest) => {
zoomLabel.textContent = latest.toFixed(1) + "×"
})
/**
* How far the photo can move at a given scale before an edge comes
* inside the frame.
*/
function limit(size, s) {
return Math.max(0, (size * (s - 1)) / 2)
}
function clampPan(value, size, s) {
const max = limit(size, s)
return Math.min(max, Math.max(-max, value))
}
/**
* Keep the photo covering the frame: once the zoom has settled, glide
* back so no edge shows. A flick carries on a little way first.
*/
function settleEdges(velocity = { x: 0, y: 0 }) {
const s = scale.get()
const { offsetWidth: width, offsetHeight: height } = viewer
animate(x, clampPan(x.get() + velocity.x * 0.2, width, s), {
...ui,
velocity: velocity.x,
})
animate(y, clampPan(y.get() + velocity.y * 0.2, height, s), {
...ui,
velocity: velocity.y,
})
}
// Wait for the pinch's own spring back to finish, then fix the edges
let settling = () => {}
function afterZoomSettles(callback) {
cancelFrame(settling)
let last
const check = (settling = () => {
const latest = scale.get()
if (latest === last) {
cancelFrame(check)
callback()
}
last = latest
})
frame.postRender(check, true)
}
let isDragging = false
/**
* Past 1x or 4x the pinch resists, and springs back inside them when
* the fingers lift, around the point between them.
*/
pinch(
photo,
() => {
// A new pinch takes over from a settle still waiting to run
cancelFrame(settling)
return () => {
// A finger still down carries on as a drag, which settles on release
if (!isDragging) afterZoomSettles(settleEdges)
}
},
{ scale: { min: 1, max: 4 }, rotate: false },
)
drag(photo, {
x,
y,
momentum: false,
onStart: () => {
isDragging = true
cancelFrame(settling)
},
onEnd: (_event, info) => {
isDragging = false
afterZoomSettles(() => settleEdges(info.velocity))
},
})
/**
* Double tap zooms in on the point that was tapped, or back out.
*/
press(
viewer,
() =>
(endEvent, { success }) => {
if (!success) return
const current = scale.get()
const target = current > 1.05 ? 1 : 2.5
const rect = viewer.getBoundingClientRect()
// The tapped point, from the frame's centre. Keyboard presses
// have no pointer position, so they zoom on the centre.
const isPointer = endEvent.clientX || endEvent.clientY
const px = isPointer ? endEvent.clientX - rect.left - rect.width / 2 : 0
const py = isPointer ? endEvent.clientY - rect.top - rect.height / 2 : 0
// Keep the tapped point under the finger as the scale changes
const ratio = target / current
const nextX = px - (px - x.get()) * ratio
const nextY = py - (py - y.get()) * ratio
animate(scale, target, ui)
animate(x, clampPan(nextX, rect.width, target), ui)
animate(y, clampPan(nextY, rect.height, target), ui)
},
{ count: 2 },
)
</script>
<style>
.pz-viewer {
width: min(340px, calc((100vh - 120px) * 0.75));
padding: 8px;
border-radius: 24px;
background: #ffffff;
box-shadow:
0 0 0 1px rgba(24, 34, 46, 0.06),
0 1px 2px rgba(24, 34, 46, 0.06),
0 12px 32px rgba(24, 34, 46, 0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
-webkit-user-select: none;
}
.pz-frame {
position: relative;
aspect-ratio: 3 / 4;
border-radius: 16px;
overflow: hidden;
background: #0b0d10;
cursor: zoom-in;
touch-action: none;
-webkit-tap-highlight-color: transparent;
/* Keeps the scaled photo clipped to the rounded corners in Safari */
isolation: isolate;
}
.pz-frame:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.pz-photo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
cursor: grab;
}
.pz-photo:active {
cursor: grabbing;
}
.pz-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 8px 6px;
}
.pz-caption {
display: flex;
flex-direction: column;
gap: 2px;
}
.pz-title {
font-size: 14px;
font-weight: 600;
color: #18222e;
}
.pz-meta {
font-size: 13px;
color: #9aa1ae;
}
.pz-status {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 130px;
height: 28px;
}
.pz-hint {
font-size: 13px;
color: #9aa1ae;
white-space: nowrap;
}
.pz-zoom {
position: absolute;
right: 0;
padding: 4px 10px;
border-radius: 999px;
background: #18222e;
color: #ffffff;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
opacity: 0;
}
</style>