09ピンチズーム
2本の指の間隔とねじれ角を測って写真がその場で拡大・縮小され回転まで追従し、マウスはホイールとドラッグで同じ動作をします。
使いどころ地図・図面・大きな写真など、2本指で見回す画面を作るとき
pointermapscalectrl+wheel

<main class="stage">
<div class="pz is-demo" id="pz-root">
<div class="pz__frame" id="pz-frame">
<div class="pz__photo" id="pz-photo">
<svg viewBox="0 0 190 128" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect class="pz__bg" width="190" height="128"/>
<circle class="pz__moon" cx="138" cy="36" r="15"/>
<polygon class="pz__t1" points="0,128 55,58 110,128"/>
<polygon class="pz__t2" points="76,128 138,70 190,118 190,128"/>
<rect class="pz__field" y="102" width="190" height="26"/>
<circle class="pz__tree" cx="30" cy="96" r="7"/>
<circle class="pz__tree pz__tree--b" cx="163" cy="92" r="5"/>
</svg>
</div>
<span class="pz__finger pz__finger--a" id="pz-fa" aria-hidden="true"></span>
<span class="pz__finger pz__finger--b" id="pz-fb" aria-hidden="true"></span>
</div>
<div class="pz__side">
<button class="pz__btn" type="button" id="pz-in" aria-label="拡大">
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8h-24v24a8 8 0 0 1-16 0v-24H80a8 8 0 0 1 0-16h24V80a8 8 0 0 1 16 0v24h24a8 8 0 0 1 8 8"/></g></svg>
</button>
<span class="pz__zoom" id="pz-zoom" aria-live="polite"><span class="pz__z pz__z--a">100%</span><span class="pz__z pz__z--b">162%</span></span>
<button class="pz__btn" type="button" id="pz-out" aria-label="縮小">
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8H80a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.pz {
display: flex;
align-items: center;
gap: $sp-4;
user-select: none;
-webkit-user-select: none;
}
.pz__frame {
position: relative;
width: 190px;
height: 128px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-lilac;
touch-action: none;
cursor: grab;
}
.pz__frame:active { cursor: grabbing; }
.pz__photo {
width: 100%;
height: 100%;
transition: transform $duration $easing;
will-change: transform;
}
.pz__photo svg { display: block; width: 100%; height: 100%; }
.pz__bg { fill: $subject-lilac; }
.pz__moon { fill: $subject-cream; }
.pz__t1 { fill: $subject-ink; }
.pz__t2 { fill: $stage-orange; }
.pz__field { fill: $subject-ink; opacity: .8; }
.pz__tree { fill: $subject-ink; }
.pz__tree--b { fill: $subject-cream; }
.pz__finger {
position: absolute;
left: -8px;
top: -8px;
width: 16px; height: 16px;
box-sizing: border-box;
border: 3px solid $subject-cream;
border-radius: 50%;
background: rgba(23, 20, 26, .5);
box-shadow: 0 0 0 4px rgba(255, 247, 230, .25);
pointer-events: none;
}
.pz__side { display: flex; flex-direction: column; align-items: center; gap: $sp-2; }
.pz__btn {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px;
border: 1px solid rgba(255, 247, 230, .55);
background: rgba(255, 247, 230, .12);
border-radius: $r-control;
color: $color;
cursor: pointer;
transition: background $dur-quick $easing, color $dur-quick $easing;
}
.pz__btn:hover { background: $color; color: $stage-orange; }
.pz__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.pz__zoom {
position: relative;
display: block;
width: 44px;
height: 14px;
font-size: 12px; line-height: 14px; font-weight: 700;
color: $subject-ink;
font-variant-numeric: tabular-nums;
text-align: center;
}
// 자동 루프에서는 두 라벨을 steps(1,end) 로 컷 교체한다 — 크로스페이드는 두 숫자가 겹쳐 보인다
.pz__z { position: absolute; left: 0; right: 0; }
.pz__z--b { opacity: 0; }
.pz.is-demo .pz__z--a { animation: pz-za $dur-loop steps(1, end) infinite; }
.pz.is-demo .pz__z--b { animation: pz-zb $dur-loop steps(1, end) infinite; }
@keyframes pz-za { 0%, 41% { opacity: 1; } 42%, 78% { opacity: 0; } 79%, 100% { opacity: 1; } }
@keyframes pz-zb { 0%, 41% { opacity: 0; } 42%, 78% { opacity: 1; } 79%, 100% { opacity: 0; } }
// ── 자동 루프: 두 손가락 벌리기(확대+회전) → 유지 → 손 떼기 → 복귀 ──
.pz.is-demo .pz__photo { animation: pz-zoom $dur-loop $easing infinite; }
@keyframes pz-zoom {
0%, 10% { transform: translate(0, 0) scale(1) rotate(0deg); }
42%, 64% { transform: translate(-7px, 5px) scale(1.62) rotate(7deg); }
90%, 100% { transform: translate(0, 0) scale(1) rotate(0deg); }
}
.pz.is-demo .pz__finger--a { animation: pz-fa $dur-loop $easing infinite; }
@keyframes pz-fa {
0%, 8% { transform: translate(76px, 64px); opacity: 0; }
12% { opacity: 1; }
42%, 64% { transform: translate(24px, 74px); opacity: 1; }
70% { opacity: 0; }
100% { transform: translate(76px, 64px); opacity: 0; }
}
.pz.is-demo .pz__finger--b { animation: pz-fb $dur-loop $easing infinite; }
@keyframes pz-fb {
0%, 8% { transform: translate(104px, 64px); opacity: 0; }
12% { opacity: 1; }
42%, 64% { transform: translate(158px, 50px); opacity: 1; }
70% { opacity: 0; }
100% { transform: translate(104px, 64px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.pz.is-demo .pz__photo,
.pz.is-demo .pz__finger,
.pz.is-demo .pz__z { animation: none !important; }
.pz__photo { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pz {
display: flex;
align-items: center;
gap: 16px;
user-select: none;
-webkit-user-select: none;
}
.pz__frame {
position: relative;
width: 190px;
height: 128px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #b9a0ff;
touch-action: none;
cursor: grab;
}
.pz__frame:active {
cursor: grabbing;
}
.pz__photo {
width: 100%;
height: 100%;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: transform;
}
.pz__photo svg {
display: block;
width: 100%;
height: 100%;
}
.pz__bg {
fill: #b9a0ff;
}
.pz__moon {
fill: #fff7e6;
}
.pz__t1 {
fill: #17141a;
}
.pz__t2 {
fill: #ff4d1f;
}
.pz__field {
fill: #17141a;
opacity: 0.8;
}
.pz__tree {
fill: #17141a;
}
.pz__tree--b {
fill: #fff7e6;
}
.pz__finger {
position: absolute;
left: -8px;
top: -8px;
width: 16px;
height: 16px;
box-sizing: border-box;
border: 3px solid #fff7e6;
border-radius: 50%;
background: rgba(23, 20, 26, 0.5);
box-shadow: 0 0 0 4px rgba(255, 247, 230, 0.25);
pointer-events: none;
}
.pz__side {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.pz__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 1px solid rgba(255, 247, 230, 0.55);
background: rgba(255, 247, 230, 0.12);
border-radius: 12px;
color: #fff7e6;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pz__btn:hover {
background: #fff7e6;
color: #ff4d1f;
}
.pz__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.pz__zoom {
position: relative;
display: block;
width: 44px;
height: 14px;
font-size: 12px;
line-height: 14px;
font-weight: 700;
color: #17141a;
font-variant-numeric: tabular-nums;
text-align: center;
}
.pz__z {
position: absolute;
left: 0;
right: 0;
}
.pz__z--b {
opacity: 0;
}
.pz.is-demo .pz__z--a {
animation: pz-za 2s steps(1, end) infinite;
}
.pz.is-demo .pz__z--b {
animation: pz-zb 2s steps(1, end) infinite;
}
@keyframes pz-za {
0%, 41% {
opacity: 1;
}
42%, 78% {
opacity: 0;
}
79%, 100% {
opacity: 1;
}
}
@keyframes pz-zb {
0%, 41% {
opacity: 0;
}
42%, 78% {
opacity: 1;
}
79%, 100% {
opacity: 0;
}
}
.pz.is-demo .pz__photo {
animation: pz-zoom 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pz-zoom {
0%, 10% {
transform: translate(0, 0) scale(1) rotate(0deg);
}
42%, 64% {
transform: translate(-7px, 5px) scale(1.62) rotate(7deg);
}
90%, 100% {
transform: translate(0, 0) scale(1) rotate(0deg);
}
}
.pz.is-demo .pz__finger--a {
animation: pz-fa 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pz-fa {
0%, 8% {
transform: translate(76px, 64px);
opacity: 0;
}
12% {
opacity: 1;
}
42%, 64% {
transform: translate(24px, 74px);
opacity: 1;
}
70% {
opacity: 0;
}
100% {
transform: translate(76px, 64px);
opacity: 0;
}
}
.pz.is-demo .pz__finger--b {
animation: pz-fb 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes pz-fb {
0%, 8% {
transform: translate(104px, 64px);
opacity: 0;
}
12% {
opacity: 1;
}
42%, 64% {
transform: translate(158px, 50px);
opacity: 1;
}
70% {
opacity: 0;
}
100% {
transform: translate(104px, 64px);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.pz.is-demo .pz__photo,
.pz.is-demo .pz__finger,
.pz.is-demo .pz__z {
animation: none !important;
}
.pz__photo {
transition: none;
}
}// 실제 동작: 두 손가락 거리→拡大, 비틀림→회전. 마우스는 휠(拡大)·드래그(이동), 버튼·화살표 키도 같은 동작.
var root = document.getElementById('pz-root');
var frame = document.getElementById('pz-frame');
var photo = document.getElementById('pz-photo');
var zoom = document.getElementById('pz-zoom');
var fa = document.getElementById('pz-fa');
var fb = document.getElementById('pz-fb');
var ptrs = {}; // pointerId -> {x, y}
var scale = 1, rot = 0, tx = 0, ty = 0;
var start = null; // 드래그/핀치 시작 스냅샷
function paint() {
photo.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ') rotate(' + rot + 'deg)';
// 자동 루프를 끈 뒤(사람이 만진 뒤)에만 숫자를 직접 쓴다 — 루프 중에는 두 라벨이 steps 로 교체된다
if (!root.classList.contains('is-demo')) zoom.textContent = Math.round(scale * 100) + '%';
}
function pinchInfo() {
var ks = Object.keys(ptrs);
if (ks.length < 2) return null;
var a = ptrs[ks[0]], b = ptrs[ks[1]];
var r = frame.getBoundingClientRect();
var ax = a.x - r.left, ay = a.y - r.top, bx = b.x - r.left, by = b.y - r.top;
fa.style.transform = 'translate(' + ax + 'px,' + ay + 'px)';
fb.style.transform = 'translate(' + bx + 'px,' + by + 'px)';
return {
d: Math.hypot(ax - bx, ay - by),
ang: Math.atan2(by - ay, bx - ax) * 180 / Math.PI,
mx: (ax + bx) / 2, my: (ay + by) / 2
};
}
frame.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
frame.setPointerCapture(e.pointerId);
ptrs[e.pointerId] = { x: e.clientX, y: e.clientY };
var info = pinchInfo();
start = { scale: scale, rot: rot, tx: tx, ty: ty,
d: info ? info.d : 0, ang: info ? info.ang : 0,
px: e.clientX, py: e.clientY };
e.preventDefault();
});
frame.addEventListener('pointermove', function (e) {
if (!(e.pointerId in ptrs)) return;
ptrs[e.pointerId] = { x: e.clientX, y: e.clientY };
var info = pinchInfo();
if (info && start && start.d > 0) {
scale = Math.max(.6, Math.min(2.4, start.scale * info.d / start.d));
var da = info.ang - start.ang;
while (da > 180) da -= 360;
while (da < -180) da += 360;
rot = Math.max(-24, Math.min(24, start.rot + da * .5));
} else if (start) {
if (scale > 1.05) { // 확대된 상태에서 한 손가락 이동(팬)
tx = start.tx + (e.clientX - start.px);
ty = start.ty + (e.clientY - start.py);
}
}
paint();
});
function up(e) {
delete ptrs[e.pointerId];
var ks = Object.keys(ptrs);
if (ks.length === 1) { // 남은 한 손가락 기준으로 팬 시작점 다시 잡기
var p = ptrs[ks[0]];
start = { scale: scale, rot: rot, tx: tx, ty: ty, d: 0, ang: 0, px: p.x, py: p.y };
}
}
frame.addEventListener('pointerup', up);
frame.addEventListener('pointercancel', up);
frame.addEventListener('wheel', function (e) {
root.classList.remove('is-demo');
scale = Math.max(.6, Math.min(2.4, scale * (e.deltaY < 0 ? 1.12 : .89)));
paint();
e.preventDefault();
}, { passive: false });
function step(di) {
root.classList.remove('is-demo');
scale = Math.max(.6, Math.min(2.4, scale + di));
paint();
}
document.getElementById('pz-in').addEventListener('click', function () { step(.2); });
document.getElementById('pz-out').addEventListener('click', function () { step(-.2); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); tx += 8; paint(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); tx -= 8; paint(); e.preventDefault(); }
else if (e.key === 'ArrowDown') { root.classList.remove('is-demo'); ty += 8; paint(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { root.classList.remove('is-demo'); ty -= 8; paint(); e.preventDefault(); }
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PinchZoom.module.scss";
export type PinchZoomProps = {
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type WEvt = { deltaY: number; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Pt = { x: number; y: number };
const IconIn = () => (
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8h-24v24a8 8 0 0 1-16 0v-24H80a8 8 0 0 1 0-16h24V80a8 8 0 0 1 16 0v24h24a8 8 0 0 1 8 8"/></g></svg>
);
const IconOut = () => (
<svg viewBox="0 0 256 256" width="15" height="15"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72m112 0a8 8 0 0 1-8 8H80a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg>
);
export default function PinchZoom({
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: PinchZoomProps) {
const [tf, setTf] = useState({ scale: 1, rot: 0, tx: 0, ty: 0 });
const frameRef = useRef<HTMLDivElement>(null);
const ptrs = useRef<Record<number, Pt>>({}) as Box<Record<number, Pt>>;
const start = useRef({ scale: 1, rot: 0, tx: 0, ty: 0, d: 0, ang: 0, px: 0, py: 0 }) as Box<{ scale: number; rot: number; tx: number; ty: number; d: number; ang: number; px: number; py: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function pinchInfo() {
const ks = Object.keys(ptrs.current);
if (ks.length < 2) return null;
const r = (frameRef.current as HTMLDivElement).getBoundingClientRect();
const a = ptrs.current[Number(ks[0])], b = ptrs.current[Number(ks[1])];
return {
d: Math.hypot(a.x - b.x, a.y - b.y),
ang: Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI,
};
}
function clampScale(s: number) { return Math.max(0.6, Math.min(2.4, s)); }
function onDown(e: PEvt) {
e.currentTarget.setPointerCapture(e.pointerId);
ptrs.current[e.pointerId] = { x: e.clientX, y: e.clientY };
const info = pinchInfo();
start.current = { scale: tf.scale, rot: tf.rot, tx: tf.tx, ty: tf.ty,
d: info ? info.d : 0, ang: info ? info.ang : 0, px: e.clientX, py: e.clientY };
e.preventDefault();
}
function onMove(e: PEvt) {
if (!(e.pointerId in ptrs.current)) return;
ptrs.current[e.pointerId] = { x: e.clientX, y: e.clientY };
const info = pinchInfo();
if (info && start.current.d > 0) {
let da = info.ang - start.current.ang;
while (da > 180) da -= 360;
while (da < -180) da += 360;
setTf((t) => ({
...t,
scale: clampScale(start.current.scale * info.d / start.current.d),
rot: Math.max(-24, Math.min(24, start.current.rot + da * 0.5)),
}));
} else if (tf.scale > 1.05) {
setTf((t) => ({ ...t, tx: start.current.tx + (e.clientX - start.current.px), ty: start.current.ty + (e.clientY - start.current.py) }));
}
}
function onUp(e: PEvt) {
delete ptrs.current[e.pointerId];
const ks = Object.keys(ptrs.current);
if (ks.length === 1) {
const p = ptrs.current[Number(ks[0])];
start.current = { ...start.current, d: 0, ang: 0, px: p.x, py: p.y };
}
}
function onWheel(e: WEvt) {
setTf((t) => ({ ...t, scale: clampScale(t.scale * (e.deltaY < 0 ? 1.12 : 0.89)) }));
e.preventDefault();
}
function step(di: number) {
setTf((t) => ({ ...t, scale: clampScale(t.scale + di) }));
}
function onKey(e: KeyEvt) {
if (e.key === "ArrowRight") { setTf((t) => ({ ...t, tx: t.tx + 8 })); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setTf((t) => ({ ...t, tx: t.tx - 8 })); e.preventDefault(); }
else if (e.key === "ArrowDown") { setTf((t) => ({ ...t, ty: t.ty + 8 })); e.preventDefault(); }
else if (e.key === "ArrowUp") { setTf((t) => ({ ...t, ty: t.ty - 8 })); e.preventDefault(); }
}
return (
<div className={styles.pz} style={vars} onKeyDown={onKey} tabIndex={0} aria-label="Pinch zoom viewer">
<div
ref={frameRef}
className={styles.frame}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onWheel={onWheel}
>
<div
className={styles.photo}
style={{ transform: `translate(${tf.tx}px, ${tf.ty}px) scale(${tf.scale}) rotate(${tf.rot}deg)` }}
>
<svg viewBox="0 0 190 128" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<rect className={styles.bg} width="190" height="128" />
<circle className={styles.moon} cx="138" cy="36" r="15" />
<polygon className={styles.t1} points="0,128 55,58 110,128" />
<polygon className={styles.t2} points="76,128 138,70 190,118 190,128" />
<rect className={styles.field} y="102" width="190" height="26" />
<circle className={styles.tree} cx="30" cy="96" r="7" />
<circle className={styles.treeB} cx="163" cy="92" r="5" />
</svg>
</div>
</div>
<div className={styles.side}>
<button type="button" className={styles.btn} aria-label="Zoom in" onClick={(): void => step(0.2)}>
<IconIn />
</button>
<span className={styles.zoom} aria-live="polite">{`${Math.round(tf.scale * 100)}%`}</span>
<button type="button" className={styles.btn} aria-label="Zoom out" onClick={(): void => step(-0.2)}>
<IconOut />
</button>
</div>
</div>
);
}09 ピンチズーム — 計測 累積面積 7.376% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · スマホ ジェスチャー操作 9選 — 指で本当に動くUI
このデモが属する記事
スマホ ジェスチャー操作 9選 — 指で本当に動くUIスマホ ジェスチャー操作とは、画面に触れた指が動いた距離と速さを読み取って表示を変える操作方法です。9つのうち2つは指を離した瞬間にはじめて結果が分かれ、1つは最後まで押し切らないと何も起きません。 この記事のデモをすべて見る


