<div class="pr-editor">
<div class="pr-frame" aria-label="Twist with two fingers to rotate">
<img
class="pr-photo"
src="/photos/japan/1.jpg"
alt="A red neon sign in Tokyo"
draggable="false"
/>
</div>
<div class="pr-controls">
<span class="pr-angle" aria-live="polite">0°</span>
<div
class="pr-dial"
role="slider"
tabindex="0"
aria-label="Rotation"
aria-valuemin="0"
aria-valuemax="359"
aria-valuenow="0"
>
<div class="pr-ticks"></div>
<div class="pr-needle"></div>
</div>
<span class="pr-hint">Twist with two fingers, or drag the dial</span>
</div>
</div>
<script type="module">
import {
animate,
motionValue,
pinch,
styleEffect,
transformValue,
} from "motion"
const photo = document.querySelector(".pr-photo")
const dial = document.querySelector(".pr-dial")
const ticks = document.querySelector(".pr-ticks")
const angle = document.querySelector(".pr-angle")
const snappy = { type: "spring", stiffness: 420, damping: 32 }
// Pixels the dial moves for each degree of rotation
const pixelsPerDegree = 3
/**
* One rotate value drives the photo, the dial and the readout.
* pinch() writes to it because it's bound to the photo with
* styleEffect.
*/
const rotate = motionValue(0)
styleEffect(photo, { rotate })
styleEffect(ticks, {
backgroundPositionX: transformValue(
// 50% puts the 0 tick under the needle
() => `calc(50% - 0.75px + ${-rotate.get() * pixelsPerDegree}px)`,
),
})
const normalise = (degrees) => ((Math.round(degrees) % 360) + 360) % 360
rotate.on("change", (latest) => {
const degrees = normalise(latest)
angle.textContent = degrees + "°"
dial.setAttribute("aria-valuenow", degrees)
})
// The dial snaps to the nearest quarter turn, like the pinch
function snap() {
animate(rotate, Math.round(rotate.get() / 90) * 90, snappy)
}
/**
* rotate.snap turns the photo to the nearest quarter turn when the
* fingers lift. Scale and pan are off, so it only turns.
*/
pinch(photo, { rotate: { snap: 90 }, scale: false, pan: false })
/**
* Trackpad pinches in Chrome and Firefox only report scale, so the
* dial rotates the photo too.
*/
dial.addEventListener("pointerdown", (event) => {
rotate.stop()
dial.setPointerCapture(event.pointerId)
const startX = event.clientX
const startRotate = rotate.get()
const move = (moveEvent) => {
if (moveEvent.pointerId !== event.pointerId) return
rotate.set(startRotate - (moveEvent.clientX - startX) / pixelsPerDegree)
}
const end = (endEvent) => {
if (endEvent.pointerId !== event.pointerId) return
window.removeEventListener("pointermove", move)
window.removeEventListener("pointerup", end)
window.removeEventListener("pointercancel", end)
snap()
}
// The window, so the drag carries on once the pointer leaves the dial
window.addEventListener("pointermove", move)
window.addEventListener("pointerup", end)
window.addEventListener("pointercancel", end)
})
dial.addEventListener("keydown", (event) => {
const direction =
event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0
if (!direction) return
event.preventDefault()
animate(rotate, Math.round(rotate.get() / 90) * 90 + direction * 90, snappy)
})
</script>
<style>
.pr-editor {
width: min(340px, calc(100vh - 220px));
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;
}
.pr-frame {
aspect-ratio: 1;
border-radius: 16px;
background:
radial-gradient(circle, rgba(24, 34, 46, 0.08) 1px, transparent 1.5px) 0
0 / 12px 12px,
#f1f3f6;
display: flex;
align-items: center;
justify-content: center;
touch-action: none;
}
.pr-photo {
width: 72%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 12px;
box-shadow:
0 1px 2px rgba(24, 34, 46, 0.12),
0 12px 28px rgba(24, 34, 46, 0.22);
display: block;
}
.pr-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 14px 8px 8px;
}
.pr-angle {
font-size: 15px;
font-weight: 600;
color: #18222e;
font-variant-numeric: tabular-nums;
}
.pr-dial {
position: relative;
width: 100%;
height: 28px;
cursor: ew-resize;
touch-action: none;
/* Ticks fade out at both ends */
mask-image: linear-gradient(
to right,
transparent,
#000 25%,
#000 75%,
transparent
);
}
.pr-dial:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 4px;
border-radius: 6px;
}
/*
* One tick every 5 degrees and a long one every 45, as a repeating
* gradient. Moving its background position scrolls it forever.
*/
.pr-ticks {
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, #18222e 1.5px, transparent 1.5px),
linear-gradient(to right, #c3c8d2 1px, transparent 1px);
background-size:
135px 18px,
15px 12px;
background-repeat: repeat-x;
background-position: calc(50% - 0.75px) center;
}
.pr-needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
border-radius: 1px;
background: #ff3b5c;
}
.pr-hint {
font-size: 13px;
color: #9aa1ae;
}
</style>