03지금 보는 자리를 알려 주는 미니맵
크게 당긴 화면 구석에 전체 그림을 줄여 놓은 미니맵이 앉아 있고, 그 안의 흰 테두리 상자가 지금 보고 있는 범위를 그대로 옮겨 그립니다. 상자의 크기와 자리는 getBoundingClientRect 로 잰 실제 값에서 나옵니다.
쓰임확대하면 어디를 보고 있는지 잃어버리는 큰 도면·일러스트
getBoundingClientRectaspect-ratiotranslate

<main class="stage">
<div class="mm is-demo" id="mm">
<div class="mm__view" id="mm-view" tabindex="0" role="img" aria-label="확대한 설계 도면. 화살표 키로 보는 자리를 옮깁니다">
<div class="mm__layer" id="mm-layer"></div>
<div class="mm__map" id="mm-map" aria-hidden="true"><div class="mm__box" id="mm-box"></div></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-yellow);
// 가짜 사진 — 파일 없이 그린 설계 도면. 확대하면 어디를 보고 있는지 잃어버리기 딱 좋은 그림
$plan:
repeating-linear-gradient(0deg, rgba(255, 247, 230, .34) 0 .5%, transparent .5% 6%),
repeating-linear-gradient(90deg, rgba(255, 247, 230, .34) 0 .5%, transparent .5% 8%),
radial-gradient(circle at 78% 30%, $subject-pink 0 9%, transparent 10%),
linear-gradient(48deg, transparent 0 38%, rgba($stage-yellow, .9) 38% 44%, transparent 44%),
linear-gradient(200deg, $stage-ink 0%, $subject-blue 58%, $stage-ink 100%);
.mm { position: relative; }
.mm__view {
position: relative;
box-sizing: border-box;
width: 288px;
height: 160px;
overflow: hidden;
border-radius: $r-card;
background: $stage-ink;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
}
.mm__view:focus-visible { outline: 3px solid $stage-ink; outline-offset: 2px; }
.mm__layer {
width: 100%;
height: 100%;
background: $plan;
transform-origin: center center;
transform: scale(2.4) translate(0, 0);
}
// 미니맵 — 같은 그림을 통째로 줄여 둔다. 비율은 aspect-ratio 로 본 화면과 맞춘다
.mm__map {
position: absolute;
right: $sp-2;
bottom: $sp-2;
box-sizing: border-box;
width: 76px;
aspect-ratio: 288 / 160;
overflow: hidden;
border: 2px solid rgba(255, 247, 230, .72);
border-radius: $r-sm;
background: $plan;
background-size: 100% 100%;
}
.mm__box {
box-sizing: border-box;
width: 41.67%;
height: 41.67%;
border: 2px solid $color;
border-radius: $r-xs;
background: rgba(255, 247, 230, .22);
transform: translate(21px, 11.7px);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 본 화면과 미니맵 상자가 같은 시간표로 움직인다. 값이 어긋나면 상자가 거짓말을 한다
.mm.is-demo .mm__layer {
animation-name: mm-layer;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mm.is-demo .mm__box {
animation-name: mm-box;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes mm-layer {
0% { transform: scale(2.4) translate(0, 0); }
26% { transform: scale(2.4) translate(-84px, -28px); }
54% { transform: scale(2.4) translate(75.6px, 32.7px); }
80% { transform: scale(2.4) translate(-33.6px, 9.3px); }
100% { transform: scale(2.4) translate(0, 0); }
}
@keyframes mm-box {
0% { transform: translate(21px, 11.7px); }
26% { transform: translate(42px, 18.7px); }
54% { transform: translate(2.1px, 3.5px); }
80% { transform: translate(29.4px, 9.4px); }
100% { transform: translate(21px, 11.7px); }
}
@media (prefers-reduced-motion: reduce) {
.mm.is-demo .mm__layer,
.mm.is-demo .mm__box { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mm {
position: relative;
}
.mm__view {
position: relative;
box-sizing: border-box;
width: 288px;
height: 160px;
overflow: hidden;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
}
.mm__view:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.mm__layer {
width: 100%;
height: 100%;
background: repeating-linear-gradient(0deg, rgba(255, 247, 230, 0.34) 0 0.5%, transparent 0.5% 6%), repeating-linear-gradient(90deg, rgba(255, 247, 230, 0.34) 0 0.5%, transparent 0.5% 8%), radial-gradient(circle at 78% 30%, #ff9ebb 0 9%, transparent 10%), linear-gradient(48deg, transparent 0 38%, rgba(255, 210, 63, 0.9) 38% 44%, transparent 44%), linear-gradient(200deg, #17141a 0%, #2f6df6 58%, #17141a 100%);
transform-origin: center center;
transform: scale(2.4) translate(0, 0);
}
.mm__map {
position: absolute;
right: 8px;
bottom: 8px;
box-sizing: border-box;
width: 76px;
aspect-ratio: 288/160;
overflow: hidden;
border: 2px solid rgba(255, 247, 230, 0.72);
border-radius: 8px;
background: repeating-linear-gradient(0deg, rgba(255, 247, 230, 0.34) 0 0.5%, transparent 0.5% 6%), repeating-linear-gradient(90deg, rgba(255, 247, 230, 0.34) 0 0.5%, transparent 0.5% 8%), radial-gradient(circle at 78% 30%, #ff9ebb 0 9%, transparent 10%), linear-gradient(48deg, transparent 0 38%, rgba(255, 210, 63, 0.9) 38% 44%, transparent 44%), linear-gradient(200deg, #17141a 0%, #2f6df6 58%, #17141a 100%);
background-size: 100% 100%;
}
.mm__box {
box-sizing: border-box;
width: 41.67%;
height: 41.67%;
border: 2px solid #fff7e6;
border-radius: 4px;
background: rgba(255, 247, 230, 0.22);
transform: translate(21px, 11.7px);
}
.mm.is-demo .mm__layer {
animation-name: mm-layer;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mm.is-demo .mm__box {
animation-name: mm-box;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes mm-layer {
0% {
transform: scale(2.4) translate(0, 0);
}
26% {
transform: scale(2.4) translate(-84px, -28px);
}
54% {
transform: scale(2.4) translate(75.6px, 32.7px);
}
80% {
transform: scale(2.4) translate(-33.6px, 9.3px);
}
100% {
transform: scale(2.4) translate(0, 0);
}
}
@keyframes mm-box {
0% {
transform: translate(21px, 11.7px);
}
26% {
transform: translate(42px, 18.7px);
}
54% {
transform: translate(2.1px, 3.5px);
}
80% {
transform: translate(29.4px, 9.4px);
}
100% {
transform: translate(21px, 11.7px);
}
}
@media (prefers-reduced-motion: reduce) {
.mm.is-demo .mm__layer,
.mm.is-demo .mm__box {
animation: none !important;
}
}// 실제 동작: 큰 화면을 옮기면 미니맵 상자가 같은 비율로 따라간다.
var mm = document.getElementById('mm');
var mmView = document.getElementById('mm-view');
var mmLayer = document.getElementById('mm-layer');
var mmMap = document.getElementById('mm-map');
var mmBox = document.getElementById('mm-box');
var MM_SCALE = 2.4;
var mmPx = 0, mmPy = 0, mmDrag = null;
function mmPaint() {
var view = mmView.getBoundingClientRect();
var map = mmMap.getBoundingClientRect();
var rest = (1 - 1 / MM_SCALE) / 2;
mmPx = Math.min(Math.max(mmPx, -1), 1);
mmPy = Math.min(Math.max(mmPy, -1), 1);
var tx = mmPx * view.width * (MM_SCALE - 1) / 2 / MM_SCALE;
var ty = mmPy * view.height * (MM_SCALE - 1) / 2 / MM_SCALE;
mmLayer.style.transform = 'scale(' + MM_SCALE + ') translate(' + tx + 'px,' + ty + 'px)';
mmBox.style.transform = 'translate(' + rest * map.width * (1 - mmPx) + 'px,' + rest * map.height * (1 - mmPy) + 'px)';
}
function mmMoveBy(dx, dy) { mm.classList.remove('is-demo'); mmPx += dx; mmPy += dy; mmPaint(); }
mmView.addEventListener('pointerdown', function (e) { mmDrag = { x: e.clientX, y: e.clientY }; mmView.setPointerCapture(e.pointerId); });
mmView.addEventListener('pointermove', function (e) {
if (!mmDrag) return;
var view = mmView.getBoundingClientRect();
mmMoveBy((e.clientX - mmDrag.x) / view.width * 2, (e.clientY - mmDrag.y) / view.height * 2);
mmDrag = { x: e.clientX, y: e.clientY };
});
mmView.addEventListener('pointerup', function () { mmDrag = null; });
mmView.addEventListener('keydown', function (e) {
var pan = { ArrowLeft: [0.2, 0], ArrowRight: [-0.2, 0], ArrowUp: [0, 0.2], ArrowDown: [0, -0.2] }[e.key];
if (!pan) return;
e.preventDefault();
mmMoveBy(pan[0], pan[1]);
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ZoomMinimap.module.scss";
export type ZoomMinimapProps = {
label?: string;
/** 본 화면 배율. style.scss 의 scale 값과 같아야 미니맵 상자가 맞는다 */
scale?: number;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DragEvt = { clientX: number; clientY: number; pointerId: number };
type KeyEvt = { key: string; preventDefault: () => void };
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
export default function ZoomMinimap({
label = "Zoomed blueprint. Move the view with the arrow keys",
scale = 2.4,
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ZoomMinimapProps) {
const view = useRef<HTMLDivElement>(null);
const map = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; y: number } | null>(null);
const [live, setLive] = useState(demo);
const [p, setP] = useState({ x: 0, y: 0 });
function moveBy(dx: number, dy: number) {
setLive(false);
setP({ x: clamp(p.x + dx, -1, 1), y: clamp(p.y + dy, -1, 1) });
}
const viewBox = view.current ? view.current.getBoundingClientRect() : null;
const mapBox = map.current ? map.current.getBoundingClientRect() : null;
const w = viewBox ? viewBox.width : 288;
const h = viewBox ? viewBox.height : 160;
const mw = mapBox ? mapBox.width : 76;
const mh = mapBox ? mapBox.height : 42;
const rest = (1 - 1 / scale) / 2;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const layer: CSSProperties = {
transform: `scale(${scale}) translate(${(p.x * w * (scale - 1)) / 2 / scale}px,${(p.y * h * (scale - 1)) / 2 / scale}px)`,
};
const box: CSSProperties = {
transform: `translate(${rest * mw * (1 - p.x)}px,${rest * mh * (1 - p.y)}px)`,
};
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div
className={styles.view}
ref={view}
tabIndex={0}
role="img"
aria-label={label}
onPointerDown={(e: DragEvt) => { drag.current = { x: e.clientX, y: e.clientY }; }}
onPointerMove={(e: DragEvt) => {
const d = drag.current;
if (!d) return;
moveBy(((e.clientX - d.x) / w) * 2, ((e.clientY - d.y) / h) * 2);
drag.current = { x: e.clientX, y: e.clientY };
}}
onPointerUp={() => { drag.current = null; }}
onKeyDown={(e: KeyEvt) => {
const pan: { [k: string]: number[] } = {
ArrowLeft: [0.2, 0], ArrowRight: [-0.2, 0], ArrowUp: [0, 0.2], ArrowDown: [0, -0.2],
};
if (!pan[e.key]) return;
e.preventDefault();
moveBy(pan[e.key][0], pan[e.key][1]);
}}
>
<div className={styles.layer} style={live ? undefined : layer} />
<div className={styles.map} ref={map} aria-hidden="true">
<div className={styles.box} style={live ? undefined : box} />
</div>
</div>
</div>
);
}03 지금 보는 자리를 알려 주는 미니맵 — 측정 누적면적 30.806% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어
이 데모가 속한 글
이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어이미지 확대 보기는 한 장의 그림을 크게 키우거나, 돌리거나, 한 부분만 짚어 보게 해 주는 화면 장치입니다. 아홉 개 모두 실제로 움직이며, 사진 파일은 한 장도 쓰지 않고 그라디언트로만 그렸습니다. 이 글의 데모 전부 보기


