01マウスを追う拡大レンズ
写真の上に丸いレンズが浮かび、その中には同じ絵を2.4倍に拡大した複製が入っています。ポインタが動くとレンズの background-position が逆方向にずれ、レンズが通る場所だけが大きく見えます。
使いどころ生地の柄や部品の表面を確認する商品詳細ページ
background-positionbackground-sizepointermove

<main class="stage">
<div class="mg is-demo" id="mg">
<div class="mg__photo" id="mg-photo" tabindex="0" role="img" aria-label="格子柄の生地。矢印キーで拡大レンズを動かします">
<div class="mg__lens" id="mg-lens" aria-hidden="true"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
// 가짜 사진 — 파일 없이 그라디언트만으로 짠 격자 원단.
// 렌즈가 같은 배경 스택을 2.4배 크기로 다시 그리므로 두 겹이 정확히 겹친다.
$photo:
repeating-linear-gradient(0deg, rgba(23, 20, 26, .26) 0 1.6%, transparent 1.6% 4%),
repeating-linear-gradient(90deg, rgba(23, 20, 26, .26) 0 1.6%, transparent 1.6% 4%),
radial-gradient(circle at 74% 24%, rgba(255, 247, 230, .5) 0 6%, transparent 7%),
linear-gradient(135deg, $stage-orange 0%, $stage-yellow 36%, $subject-mint 68%, $subject-blue 100%);
$pw: 288px;
$ph: 168px;
$zoom: 2.4;
$lens: 88px;
.mg { position: relative; }
.mg__photo {
position: relative;
box-sizing: border-box;
width: $pw;
height: $ph;
overflow: hidden;
border-radius: $r-card;
background: $photo;
background-size: $pw $ph;
cursor: crosshair;
}
.mg__photo:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.mg__lens {
position: absolute;
left: 0;
top: 0;
width: $lens;
height: $lens;
border: 3px solid $color;
border-radius: 50%;
background: $photo;
background-size: $pw * $zoom $ph * $zoom;
background-position: -138.4px -100px; // 기본 자리 = 루프 1번 지점
box-shadow: $shadow-raised;
transform: translate(32px, 16px);
pointer-events: none;
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 세 지점을 P1 → P2 → P3 → P2 → P1 으로 왕복한다. 끝점이 시작점이라 이음매가 없다.
.mg.is-demo .mg__lens {
animation-name: mg-lens;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes mg-lens {
0% { transform: translate(32px, 16px); background-position: -138.4px -100px; }
28% { transform: translate(106px, 68px); background-position: -316px -224.8px; }
52% { transform: translate(188px, 18px); background-position: -512.8px -104.8px; }
78% { transform: translate(106px, 68px); background-position: -316px -224.8px; }
100% { transform: translate(32px, 16px); background-position: -138.4px -100px; }
}
@media (prefers-reduced-motion: reduce) {
.mg.is-demo .mg__lens { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mg {
position: relative;
}
.mg__photo {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: repeating-linear-gradient(0deg, rgba(23, 20, 26, 0.26) 0 1.6%, transparent 1.6% 4%), repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.26) 0 1.6%, transparent 1.6% 4%), radial-gradient(circle at 74% 24%, rgba(255, 247, 230, 0.5) 0 6%, transparent 7%), linear-gradient(135deg, #ff4d1f 0%, #ffd23f 36%, #4cd4a6 68%, #2f6df6 100%);
background-size: 288px 168px;
cursor: crosshair;
}
.mg__photo:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.mg__lens {
position: absolute;
left: 0;
top: 0;
width: 88px;
height: 88px;
border: 3px solid #fff7e6;
border-radius: 50%;
background: repeating-linear-gradient(0deg, rgba(23, 20, 26, 0.26) 0 1.6%, transparent 1.6% 4%), repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.26) 0 1.6%, transparent 1.6% 4%), radial-gradient(circle at 74% 24%, rgba(255, 247, 230, 0.5) 0 6%, transparent 7%), linear-gradient(135deg, #ff4d1f 0%, #ffd23f 36%, #4cd4a6 68%, #2f6df6 100%);
background-size: 691.2px 403.2px;
background-position: -138.4px -100px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translate(32px, 16px);
pointer-events: none;
}
.mg.is-demo .mg__lens {
animation-name: mg-lens;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes mg-lens {
0% {
transform: translate(32px, 16px);
background-position: -138.4px -100px;
}
28% {
transform: translate(106px, 68px);
background-position: -316px -224.8px;
}
52% {
transform: translate(188px, 18px);
background-position: -512.8px -104.8px;
}
78% {
transform: translate(106px, 68px);
background-position: -316px -224.8px;
}
100% {
transform: translate(32px, 16px);
background-position: -138.4px -100px;
}
}
@media (prefers-reduced-motion: reduce) {
.mg.is-demo .mg__lens {
animation: none !important;
}
}// 실제 동작: 포인터가 가리키는 지점을 2.4배로 키워 렌즈 안에 보여 준다.
var mg = document.getElementById('mg');
var mgPhoto = document.getElementById('mg-photo');
var mgLens = document.getElementById('mg-lens');
var MG_ZOOM = 2.4;
var mgX = 76, mgY = 60;
function mgPaint() {
var box = mgPhoto.getBoundingClientRect();
var half = mgLens.offsetWidth / 2;
mgX = Math.min(Math.max(mgX, half), box.width - half);
mgY = Math.min(Math.max(mgY, half), box.height - half);
mgLens.style.transform = 'translate(' + (mgX - half) + 'px,' + (mgY - half) + 'px)';
mgLens.style.backgroundPosition = -(mgX * MG_ZOOM - half) + 'px ' + -(mgY * MG_ZOOM - half) + 'px';
}
mgPhoto.addEventListener('pointermove', function (e) {
mg.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var box = mgPhoto.getBoundingClientRect();
mgX = e.clientX - box.left;
mgY = e.clientY - box.top;
mgPaint();
});
mgPhoto.addEventListener('keydown', function (e) {
var step = { ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16] }[e.key];
if (!step) return;
e.preventDefault();
mg.classList.remove('is-demo');
mgX += step[0]; mgY += step[1];
mgPaint();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MagnifierLens.module.scss";
export type MagnifierLensProps = {
label?: string;
/** 렌즈 안 배율. style.scss 의 $zoom 과 같은 값이어야 두 겹이 겹친다 */
zoom?: number;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault: () => void };
const HALF = 44;
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
export default function MagnifierLens({
label = "Checked fabric. Move the magnifier with the arrow keys",
zoom = 2.4,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: MagnifierLensProps) {
const photo = useRef<HTMLDivElement>(null);
const [live, setLive] = useState(demo);
const [at, setAt] = useState({ x: 76, y: 60 });
function move(x: number, y: number) {
const box = photo.current ? photo.current.getBoundingClientRect() : null;
const w = box ? box.width : 288;
const h = box ? box.height : 168;
setLive(false);
setAt({ x: clamp(x, HALF, w - HALF), y: clamp(y, HALF, h - HALF) });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const lens: CSSProperties = {
transform: `translate(${at.x - HALF}px,${at.y - HALF}px)`,
backgroundPosition: `${-(at.x * zoom - HALF)}px ${-(at.y * zoom - HALF)}px`,
};
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div
className={styles.photo}
ref={photo}
tabIndex={0}
role="img"
aria-label={label}
onPointerMove={(e: PointerEvt) => {
const box = photo.current ? photo.current.getBoundingClientRect() : { left: 0, top: 0 };
move(e.clientX - box.left, e.clientY - box.top);
}}
onKeyDown={(e: KeyEvt) => {
const step: { [k: string]: number[] } = {
ArrowLeft: [-16, 0], ArrowRight: [16, 0], ArrowUp: [0, -16], ArrowDown: [0, 16],
};
if (!step[e.key]) return;
e.preventDefault();
move(at.x + step[e.key][0], at.y + step[e.key][1]);
}}
>
<div className={styles.lens} aria-hidden="true" style={live ? undefined : lens} />
</div>
</div>
);
}01 マウスを追う拡大レンズ — 計測 累積面積 20.113% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 画像 拡大 表示 UI 9選 — 写真ファイルなしで作るビューア
このデモが属する記事
画像 拡大 表示 UI 9選 — 写真ファイルなしで作るビューア画像 拡大 表示とは、一枚の絵を大きく、回して、あるいは一か所だけ指して見せる画面の仕組みです。9つとも実際に動き、写真ファイルは一枚も使わず、グラデーションだけで描いています。 この記事のデモをすべて見る


