이미지 확대 보기 UI 9종 — 사진 파일 없이 만드는 뷰어
이미지 확대 보기는 한 장의 그림을 크게 키우거나, 돌리거나, 한 부분만 짚어 보게 해 주는 화면 장치입니다. 아홉 개 모두 실제로 움직이며, 사진 파일은 한 장도 쓰지 않고 그라디언트로만 그렸습니다.
01 viewer-magnifier-lens
02 viewer-wheel-zoom-pan
03 viewer-zoom-minimap
04 viewer-zoom-level-control
05 viewer-rotate-flip-toolbar
06 viewer-lightbox-flip
07 viewer-360-spin-drag
08 viewer-spotlight-mask
09 viewer-hotspot-pin-popover
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 마우스를 따라가는 확대 렌즈
- 02 휠로 당기고 끌어서 옮기는 뷰어
- 03 지금 보는 자리를 알려 주는 미니맵
- 04 단계로 끊어 주는 확대율 조절 바
- 05 돌리고 뒤집는 툴바
- 06 썸네일 자리에서 자라나는 라이트박스
- 07 끌어서 한 바퀴 돌리는 360도 뷰
- 08 포인터를 따라 켜지는 스포트라이트
- 09 사진 위에 꽂힌 설명 핀
순서는 인기순이 아니라 한 사람이 그림을 들여다보는 순서입니다. 먼저 누르지 않고 한 부분만 크게 보고(01), 그다음 화면 전체를 당겨 끌어 옮깁니다(02). 당기고 나면 어디를 보고 있는지 놓치기 쉬우므로 미니맵이 붙고(03), 배율을 정확히 맞춰야 할 때 단계 컨트롤이 붙습니다(04). 방향이 틀어진 그림은 돌려 세우고(05), 작게 늘어놓은 것 중 하나는 크게 엽니다(06). 물건이라면 뒤까지 돌려 보고(07), 설명할 때는 한 곳만 밝히거나(08) 그 자리에 이름을 꽂습니다(09). 돋보기 확대 이미지부터 아홉 개 전부 바닐라 자바스크립트 예순 줄 안쪽이고, 안에 들어 있는 그림은 linear-gradient 와 radial-gradient 계열뿐입니다. 이 뷰어의 결과물을 나열해 꾸미는 판은 포트폴리오 갤러리 UI이고, 확대 축을 세로로 세우면 마우스를 따라오는 CSS 3D 회전 장면이 됩니다. 렌즈 안 배율은 2배와 3배 두 단계로만 묶어 사이값을 없앴고, 미니맵의 뷰 박스는 렌즈가 아니라 커서를 따라가게 했습니다. 배율 단계가 많아지면 손이 멈춘 뒤에도 눈이 쫓아가야 해서 두 단계에서 끊었고, 휠 한 칸에 배율이 한 단계씩만 오르내리도록 조임도 넣었습니다.
01마우스를 따라가는 확대 렌즈
css 이미지 확대에서 무엇이 보이나
원단 위에 뜬 동그란 렌즈 안에는 같은 배경을 2.4배 크기로 다시 깐 사본이 들어 있습니다. 포인터 좌표에 배율을 곱하고 렌즈 반지름만큼 뺀 값이 그대로 background-position 이 되므로, 두 겹이 어긋나지 않고 딱 맞물립니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
렌즈가 그림 밖으로 나가지 않도록 좌표를 반지름과 그림 폭 사이로 잘라 냅니다.
var MG_ZOOM = 2.4;
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) {
var box = mgPhoto.getBoundingClientRect();
mgX = e.clientX - box.left;
mgY = e.clientY - box.top;
mgPaint();
});
02휠로 당기고 끌어서 옮기는 뷰어
화면에서 일어나는 일
휠을 굴리면 배율이 1배와 3배 사이에서 오르내리고, 끌면 지도가 손끝을 따라옵니다. 옮길 수 있는 거리는 (폭 × (배율 − 1)) ÷ 2 ÷ 배율 로 미리 구해 두고 그 밖으로는 못 나가게 막습니다.

<main class="stage">
<div class="zp is-demo" id="zp">
<div class="zp__view" id="zp-view" tabindex="0" role="img" aria-label="도시 지도. 휠로 확대하고 화살표 키로 옮깁니다">
<div class="zp__layer" id="zp-layer"></div>
<span class="zp__badge" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><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></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 가짜 사진 — 파일 없이 그린 도시 지도. 골목이 촘촘해서 당길수록 볼 것이 늘어난다
$map:
repeating-linear-gradient(0deg, rgba(255, 247, 230, .82) 0 .7%, transparent .7% 9%),
repeating-linear-gradient(90deg, rgba(255, 247, 230, .82) 0 .7%, transparent .7% 12%),
linear-gradient(62deg, transparent 0 44%, $subject-sky 44% 53%, transparent 53%),
radial-gradient(circle at 22% 72%, $subject-mint 0 12%, transparent 13%),
linear-gradient(135deg, $subject-blue 0%, $subject-mint 46%, $subject-lilac 100%);
.zp { position: relative; }
.zp__view {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $stage-ink;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
}
.zp__view:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.zp__layer {
width: 100%;
height: 100%;
background: $map;
transform-origin: center center;
transform: scale(1) translate(0, 0);
}
.zp__badge {
position: absolute;
right: $sp-2;
bottom: $sp-2;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(23, 20, 26, .72);
color: $subject-cream;
pointer-events: none;
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 1배에서 2.2배까지 당겼다가 두 곳을 둘러보고 다시 1배로 돌아온다. 끝과 처음이 같은 값이다
.zp.is-demo .zp__layer {
animation-name: zp-pan;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes zp-pan {
0% { transform: scale(1) translate(0, 0); }
30% { transform: scale(2.2) translate(-38px, -22px); }
62% { transform: scale(2.2) translate(34px, 18px); }
100% { transform: scale(1) translate(0, 0); }
}
@media (prefers-reduced-motion: reduce) {
.zp.is-demo .zp__layer { animation: none !important; transform: scale(1.6) translate(0, 0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.zp {
position: relative;
}
.zp__view {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
}
.zp__view:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.zp__layer {
width: 100%;
height: 100%;
background: repeating-linear-gradient(0deg, rgba(255, 247, 230, 0.82) 0 0.7%, transparent 0.7% 9%), repeating-linear-gradient(90deg, rgba(255, 247, 230, 0.82) 0 0.7%, transparent 0.7% 12%), linear-gradient(62deg, transparent 0 44%, #6ec8ff 44% 53%, transparent 53%), radial-gradient(circle at 22% 72%, #4cd4a6 0 12%, transparent 13%), linear-gradient(135deg, #2f6df6 0%, #4cd4a6 46%, #b9a0ff 100%);
transform-origin: center center;
transform: scale(1) translate(0, 0);
}
.zp__badge {
position: absolute;
right: 8px;
bottom: 8px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(23, 20, 26, 0.72);
color: #fff7e6;
pointer-events: none;
}
.zp.is-demo .zp__layer {
animation-name: zp-pan;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes zp-pan {
0% {
transform: scale(1) translate(0, 0);
}
30% {
transform: scale(2.2) translate(-38px, -22px);
}
62% {
transform: scale(2.2) translate(34px, 18px);
}
100% {
transform: scale(1) translate(0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.zp.is-demo .zp__layer {
animation: none !important;
transform: scale(1.6) translate(0, 0);
}
}// 실제 동작: 휠은 배율, 드래그는 위치. 옮길 수 있는 거리는 배율에서 계산해 잘라 낸다.
var zp = document.getElementById('zp');
var zpView = document.getElementById('zp-view');
var zpLayer = document.getElementById('zp-layer');
var zpScale = 1, zpX = 0, zpY = 0, zpDrag = null;
function zpPaint() {
var box = zpView.getBoundingClientRect();
var maxX = box.width * (zpScale - 1) / 2 / zpScale;
var maxY = box.height * (zpScale - 1) / 2 / zpScale;
zpX = Math.min(Math.max(zpX, -maxX), maxX);
zpY = Math.min(Math.max(zpY, -maxY), maxY);
zpLayer.style.transform = 'scale(' + zpScale + ') translate(' + zpX + 'px,' + zpY + 'px)';
}
function zpZoom(d) { zp.classList.remove('is-demo'); zpScale = Math.min(Math.max(zpScale + d, 1), 3); zpPaint(); }
zpView.addEventListener('wheel', function (e) { e.preventDefault(); zpZoom(-e.deltaY / 400); }, { passive: false });
zpView.addEventListener('pointerdown', function (e) {
zp.classList.remove('is-demo');
zpDrag = { x: e.clientX, y: e.clientY };
zpView.setPointerCapture(e.pointerId);
});
zpView.addEventListener('pointermove', function (e) {
if (!zpDrag) return;
zpX += (e.clientX - zpDrag.x) / zpScale;
zpY += (e.clientY - zpDrag.y) / zpScale;
zpDrag = { x: e.clientX, y: e.clientY };
zpPaint();
});
zpView.addEventListener('pointerup', function () { zpDrag = null; });
zpView.addEventListener('keydown', function (e) {
var pan = { ArrowLeft: [24, 0], ArrowRight: [-24, 0], ArrowUp: [0, 24], ArrowDown: [0, -24] }[e.key];
if (pan) { e.preventDefault(); zp.classList.remove('is-demo'); zpX += pan[0]; zpY += pan[1]; zpPaint(); }
if (e.key === '+' || e.key === '=') { e.preventDefault(); zpZoom(0.4); }
if (e.key === '-') { e.preventDefault(); zpZoom(-0.4); }
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./WheelZoomPan.module.scss";
export type WheelZoomPanProps = {
label?: string;
minScale?: number;
maxScale?: number;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type WheelEvt = { deltaY: number; preventDefault: () => void };
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 WheelZoomPan({
label = "City map. Zoom with the wheel, pan with the arrow keys",
minScale = 1,
maxScale = 3,
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: WheelZoomPanProps) {
const view = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; y: number } | null>(null);
const [live, setLive] = useState(demo);
const [scale, setScale] = useState(1);
const [at, setAt] = useState({ x: 0, y: 0 });
function fit(next: number, x: number, y: number) {
const box = view.current ? view.current.getBoundingClientRect() : null;
const w = box ? box.width : 288;
const h = box ? box.height : 168;
const maxX = (w * (next - 1)) / 2 / next;
const maxY = (h * (next - 1)) / 2 / next;
setLive(false);
setScale(next);
setAt({ x: clamp(x, -maxX, maxX), y: clamp(y, -maxY, maxY) });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const layer: CSSProperties = { transform: `scale(${scale}) translate(${at.x}px,${at.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}
onWheel={(e: WheelEvt) => {
e.preventDefault();
fit(clamp(scale - e.deltaY / 400, minScale, maxScale), at.x, at.y);
}}
onPointerDown={(e: DragEvt) => { drag.current = { x: e.clientX, y: e.clientY }; }}
onPointerMove={(e: DragEvt) => {
const d = drag.current;
if (!d) return;
fit(scale, at.x + (e.clientX - d.x) / scale, at.y + (e.clientY - d.y) / scale);
drag.current = { x: e.clientX, y: e.clientY };
}}
onPointerUp={() => { drag.current = null; }}
onKeyDown={(e: KeyEvt) => {
const pan: { [k: string]: number[] } = {
ArrowLeft: [24, 0], ArrowRight: [-24, 0], ArrowUp: [0, 24], ArrowDown: [0, -24],
};
if (pan[e.key]) {
e.preventDefault();
fit(scale, at.x + pan[e.key][0], at.y + pan[e.key][1]);
}
if (e.key === "+" || e.key === "=") { e.preventDefault(); fit(clamp(scale + 0.4, minScale, maxScale), at.x, at.y); }
if (e.key === "-") { e.preventDefault(); fit(clamp(scale - 0.4, minScale, maxScale), at.x, at.y); }
}}
>
<div className={styles.layer} style={live ? undefined : layer} />
<span className={styles.badge} aria-hidden="true" />
</div>
</div>
);
}02 · 휠로 당기고 끌어서 옮기는 뷰어 — 측정 누적면적 33.417% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
이 한 줄이 없으면 조금만 세게 끌어도 그림이 빠져나가 검은 여백이 드러납니다.
function zpPaint() {
var box = zpView.getBoundingClientRect();
var maxX = box.width * (zpScale - 1) / 2 / zpScale;
var maxY = box.height * (zpScale - 1) / 2 / zpScale;
zpX = Math.min(Math.max(zpX, -maxX), maxX);
zpY = Math.min(Math.max(zpY, -maxY), maxY);
zpLayer.style.transform = 'scale(' + zpScale + ') translate(' + zpX + 'px,' + zpY + 'px)';
}
zpView.addEventListener('wheel', function (e) {
e.preventDefault();
zpScale = Math.min(Math.max(zpScale - e.deltaY / 400, 1), 3);
zpPaint();
}, { passive: false });
03지금 보는 자리를 알려 주는 미니맵
어떤 움직임인가
오른쪽 아래 미니맵은 같은 도면을 통째로 줄여 둔 것이고, 그 안의 흰 상자가 지금 화면에 담긴 범위입니다. 배율이 2.4배면 상자는 미니맵의 1÷2.4, 곧 41.67퍼센트 크기이고 남는 58.33퍼센트는 상자가 돌아다닐 길이 됩니다.

<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 포함 · 비밀번호 보기
어디를 바꾸면 되나
큰 화면과 상자는 같은 값 하나에서 갈라져 나오므로 둘이 어긋날 수 없습니다.
var MM_SCALE = 2.4;
function mmPaint() {
var view = mmView.getBoundingClientRect();
var map = mmMap.getBoundingClientRect();
var rest = (1 - 1 / MM_SCALE) / 2;
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)';
}
04단계로 끊어 주는 확대율 조절 바
무엇이 보이나
배율은 배열에 적힌 네 값만 밟습니다. 끝에 닿으면 해당 버튼이 disabled 로 꺼져서, 눌러도 아무 반응이 없는 버튼을 계속 누르는 일이 없습니다.

<main class="stage">
<div class="zl is-demo" id="zl">
<div class="zl__view"><div class="zl__layer" id="zl-layer"></div></div>
<div class="zl__bar">
<button class="zl__btn" id="zl-out" type="button" aria-label="한 단계 축소"><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg></button>
<span class="zl__pct" id="zl-pct" role="status" aria-live="polite">
<span class="zl__n zl__n--1">100%</span><span class="zl__n zl__n--2">150%</span><span class="zl__n zl__n--3">200%</span><span class="zl__n zl__n--4">300%</span>
</span>
<button class="zl__btn" id="zl-in" type="button" aria-label="한 단계 확대"><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg></button>
<button class="zl__btn zl__btn--fit" id="zl-fit" type="button" aria-label="화면에 맞추기"><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M208 48v160H48V48Z" opacity=".2"/><path d="M216 48v48a8 8 0 0 1-16 0V67.31l-42.34 42.35a8 8 0 0 1-11.32-11.32L188.69 56H160a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8M98.34 146.34L56 188.69V160a8 8 0 0 0-16 0v48a8 8 0 0 0 8 8h48a8 8 0 0 0 0-16H67.31l42.35-42.34a8 8 0 0 0-11.32-11.32M208 152a8 8 0 0 0-8 8v28.69l-42.34-42.35a8 8 0 0 0-11.32 11.32L188.69 200H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M67.31 56H96a8 8 0 0 0 0-16H48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31l42.34 42.35a8 8 0 0 0 11.32-11.32Z"/></g></svg></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-orange);
// 가짜 사진 — 파일 없이 그린 문서 한 장. 배율이 바뀌면 글줄 굵기가 그대로 커진다
$doc:
radial-gradient(circle at 82% 16%, $subject-pink 0 6%, transparent 7%),
repeating-linear-gradient(0deg, rgba(23, 20, 26, .68) 0 2.4%, transparent 2.4% 9%),
linear-gradient($subject-cream, $subject-cream);
.zl {
display: flex;
flex-direction: column;
gap: $sp-2;
width: 288px;
}
.zl__view {
box-sizing: border-box;
width: 100%;
height: 124px;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
}
.zl__layer {
width: 100%;
height: 100%;
background: $doc;
transform-origin: center center;
transform: scale(1);
}
.zl__bar {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-2;
height: 36px;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .88);
color: $subject-cream;
}
.zl__btn {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(255, 247, 230, .14);
color: inherit;
cursor: pointer;
}
.zl__btn:disabled { opacity: .38; cursor: default; }
.zl__btn:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.zl__btn--fit { background: rgba(255, 210, 63, .32); }
.zl__pct {
position: relative;
width: 56px;
height: 20px;
text-align: center;
font-size: 13px;
line-height: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.zl__n {
position: absolute;
left: 0;
top: 0;
width: 100%;
opacity: 0;
}
.zl__n--1 { opacity: 1; } // 루프 밖 기본 표시
// ── 자동 루프 (2s) ────────────────────────────────────
// 100 → 150 → 200 → 300 → 200 → 150 → 100. 올라간 길로 되돌아오므로 끝에서 튀지 않는다.
// 단계가 끊어지는 컨트롤이라 배율도 숫자도 steps(1, end) 로 한 프레임에 갈아 끼운다
.zl.is-demo .zl__layer,
.zl.is-demo .zl__n {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.zl.is-demo .zl__layer { animation-name: zl-zoom; }
.zl.is-demo .zl__n--1 { animation-name: zl-n1; }
.zl.is-demo .zl__n--2 { animation-name: zl-n2; }
.zl.is-demo .zl__n--3 { animation-name: zl-n3; }
.zl.is-demo .zl__n--4 { animation-name: zl-n4; }
@keyframes zl-zoom {
0% { transform: scale(1); }
14% { transform: scale(1.5); }
28% { transform: scale(2); }
44% { transform: scale(3); }
58% { transform: scale(2); }
72% { transform: scale(1.5); }
86% { transform: scale(1); }
100% { transform: scale(1); }
}
@keyframes zl-n1 {
0% { opacity: 1; }
14% { opacity: 0; }
86% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes zl-n2 {
0% { opacity: 0; }
14% { opacity: 1; }
28% { opacity: 0; }
72% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes zl-n3 {
0% { opacity: 0; }
28% { opacity: 1; }
44% { opacity: 0; }
58% { opacity: 1; }
72% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes zl-n4 {
0% { opacity: 0; }
44% { opacity: 1; }
58% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 360px) {
.zl__view { height: 116px; }
}
@media (prefers-reduced-motion: reduce) {
.zl.is-demo .zl__layer,
.zl.is-demo .zl__n { animation: none !important; }
.zl.is-demo .zl__layer { transform: scale(2); }
.zl.is-demo .zl__n--1 { opacity: 0; }
.zl.is-demo .zl__n--3 { opacity: 1; }
}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);
}
.zl {
display: flex;
flex-direction: column;
gap: 8px;
width: 288px;
}
.zl__view {
box-sizing: border-box;
width: 100%;
height: 124px;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.zl__layer {
width: 100%;
height: 100%;
background: radial-gradient(circle at 82% 16%, #ff9ebb 0 6%, transparent 7%), repeating-linear-gradient(0deg, rgba(23, 20, 26, 0.68) 0 2.4%, transparent 2.4% 9%), linear-gradient(#fff7e6, #fff7e6);
transform-origin: center center;
transform: scale(1);
}
.zl__bar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 36px;
padding: 0 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.88);
color: #fff7e6;
}
.zl__btn {
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(255, 247, 230, 0.14);
color: inherit;
cursor: pointer;
}
.zl__btn:disabled {
opacity: 0.38;
cursor: default;
}
.zl__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.zl__btn--fit {
background: rgba(255, 210, 63, 0.32);
}
.zl__pct {
position: relative;
width: 56px;
height: 20px;
text-align: center;
font-size: 13px;
line-height: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.zl__n {
position: absolute;
left: 0;
top: 0;
width: 100%;
opacity: 0;
}
.zl__n--1 {
opacity: 1;
}
.zl.is-demo .zl__layer,
.zl.is-demo .zl__n {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.zl.is-demo .zl__layer {
animation-name: zl-zoom;
}
.zl.is-demo .zl__n--1 {
animation-name: zl-n1;
}
.zl.is-demo .zl__n--2 {
animation-name: zl-n2;
}
.zl.is-demo .zl__n--3 {
animation-name: zl-n3;
}
.zl.is-demo .zl__n--4 {
animation-name: zl-n4;
}
@keyframes zl-zoom {
0% {
transform: scale(1);
}
14% {
transform: scale(1.5);
}
28% {
transform: scale(2);
}
44% {
transform: scale(3);
}
58% {
transform: scale(2);
}
72% {
transform: scale(1.5);
}
86% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@keyframes zl-n1 {
0% {
opacity: 1;
}
14% {
opacity: 0;
}
86% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes zl-n2 {
0% {
opacity: 0;
}
14% {
opacity: 1;
}
28% {
opacity: 0;
}
72% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes zl-n3 {
0% {
opacity: 0;
}
28% {
opacity: 1;
}
44% {
opacity: 0;
}
58% {
opacity: 1;
}
72% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes zl-n4 {
0% {
opacity: 0;
}
44% {
opacity: 1;
}
58% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.zl__view {
height: 116px;
}
}
@media (prefers-reduced-motion: reduce) {
.zl.is-demo .zl__layer,
.zl.is-demo .zl__n {
animation: none !important;
}
.zl.is-demo .zl__layer {
transform: scale(2);
}
.zl.is-demo .zl__n--1 {
opacity: 0;
}
.zl.is-demo .zl__n--3 {
opacity: 1;
}
}// 실제 동작: 배율은 네 단계뿐이다. 사이 값으로는 절대 서지 않는다.
var ZL_STOPS = [1, 1.5, 2, 3];
var zl = document.getElementById('zl');
var zlLayer = document.getElementById('zl-layer');
var zlPct = document.getElementById('zl-pct');
var zlStep = 0;
function zlPaint() {
zl.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
zlLayer.style.transform = 'scale(' + ZL_STOPS[zlStep] + ')';
zlPct.textContent = Math.round(ZL_STOPS[zlStep] * 100) + '%';
document.getElementById('zl-out').disabled = zlStep === 0;
document.getElementById('zl-in').disabled = zlStep === ZL_STOPS.length - 1;
}
document.getElementById('zl-out').addEventListener('click', function () { zlStep = Math.max(zlStep - 1, 0); zlPaint(); });
document.getElementById('zl-in').addEventListener('click', function () { zlStep = Math.min(zlStep + 1, ZL_STOPS.length - 1); zlPaint(); });
document.getElementById('zl-fit').addEventListener('click', function () { zlStep = 0; zlPaint(); });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ZoomLevelControl.module.scss";
export type ZoomLevelControlProps = {
/** 배율 단계. 사이 값으로는 서지 않는다 */
stops?: number[];
outLabel?: string;
inLabel?: string;
fitLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Minus = () => (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8" />
</g>
</svg>
);
const Plus = () => (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8" />
</g>
</svg>
);
const Fit = () => (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M208 48v160H48V48Z" opacity=".2" />
<path d="M216 48v48a8 8 0 0 1-16 0V67.31l-42.34 42.35a8 8 0 0 1-11.32-11.32L188.69 56H160a8 8 0 0 1 0-16h48a8 8 0 0 1 8 8M98.34 146.34L56 188.69V160a8 8 0 0 0-16 0v48a8 8 0 0 0 8 8h48a8 8 0 0 0 0-16H67.31l42.35-42.34a8 8 0 0 0-11.32-11.32M208 152a8 8 0 0 0-8 8v28.69l-42.34-42.35a8 8 0 0 0-11.32 11.32L188.69 200H160a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8v-48a8 8 0 0 0-8-8M67.31 56H96a8 8 0 0 0 0-16H48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0V67.31l42.34 42.35a8 8 0 0 0 11.32-11.32Z" />
</g>
</svg>
);
export default function ZoomLevelControl({
stops = [1, 1.5, 2, 3],
outLabel = "Zoom out one step",
inLabel = "Zoom in one step",
fitLabel = "Fit to screen",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: ZoomLevelControlProps) {
const [live, setLive] = useState(demo);
const [step, setStep] = useState(0);
function go(next: number) {
setLive(false);
setStep(Math.min(Math.max(next, 0), stops.length - 1));
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const layer: CSSProperties = { transform: `scale(${stops[step]})` };
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.view}>
<div className={styles.layer} style={live ? undefined : layer} />
</div>
<div className={styles.bar}>
<button className={styles.btn} type="button" aria-label={outLabel} disabled={!live && step === 0} onClick={() => go(step - 1)}>
<Minus />
</button>
<span className={styles.pct} role="status" aria-live="polite">
{live ? (
<>
<span className={[styles.n, styles.n1].join(" ")}>100%</span>
<span className={[styles.n, styles.n2].join(" ")}>150%</span>
<span className={[styles.n, styles.n3].join(" ")}>200%</span>
<span className={[styles.n, styles.n4].join(" ")}>300%</span>
</>
) : (
`${Math.round(stops[step] * 100)}%`
)}
</span>
<button className={styles.btn} type="button" aria-label={inLabel} disabled={!live && step === stops.length - 1} onClick={() => go(step + 1)}>
<Plus />
</button>
<button className={[styles.btn, styles.btnFit].join(" ")} type="button" aria-label={fitLabel} onClick={() => go(0)}>
<Fit />
</button>
</div>
</div>
);
}04 · 단계로 끊어 주는 확대율 조절 바 — 측정 누적면적 21.867% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
숫자는 role="status" 안에 있어 화면을 못 보는 사람에게도 바뀐 배율이 한 번 전해집니다.
var ZL_STOPS = [1, 1.5, 2, 3];
function zlPaint() {
zlLayer.style.transform = 'scale(' + ZL_STOPS[zlStep] + ')';
zlPct.textContent = Math.round(ZL_STOPS[zlStep] * 100) + '%';
document.getElementById('zl-out').disabled = zlStep === 0;
document.getElementById('zl-in').disabled = zlStep === ZL_STOPS.length - 1;
}
document.getElementById('zl-in').addEventListener('click', function () {
zlStep = Math.min(zlStep + 1, ZL_STOPS.length - 1);
zlPaint();
});
05돌리고 뒤집는 툴바
화면에서 일어나는 일
각도는 90도씩 더하기만 하고 360도에서 0도로 되돌리지 않습니다. 값을 계속 키우면 회전이 늘 같은 방향으로 이어져 되감기는 장면이 안 나옵니다.

<main class="stage">
<div class="rf is-demo" id="rf">
<div class="rf__view"><div class="rf__paper" id="rf-paper" role="img" aria-label="옆으로 찍힌 영수증 스캔본"></div></div>
<div class="rf__bar">
<button class="rf__btn" id="rf-turn" type="button" aria-label="90도 돌리기"><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0"/></g></svg></button>
<button class="rf__btn" id="rf-flip" type="button" aria-pressed="false" aria-label="좌우 뒤집기"><svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true"><g fill="currentColor"><path d="M112 40v160a8 8 0 0 1-8 8H40a8 8 0 0 1-7.37-11.12l64-160C100 28.86 112 31.29 112 40" opacity=".2"/><path d="M107.18 24.33a15.86 15.86 0 0 0-17.92 9.45l-.06.14l-64 159.93A16 16 0 0 0 40 216h64a16 16 0 0 0 16-16V40a15.85 15.85 0 0 0-12.82-15.67M104 200H40l.06-.15L104 40Zm126.77-6.15l-64-159.93l-.06-.14A16 16 0 0 0 136 40v160a16 16 0 0 0 16 16h64a16 16 0 0 0 14.78-22.15ZM152 200V40l63.93 159.84l.06.15Z"/></g></svg></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
// 가짜 사진 — 파일 없이 그린 영수증 스캔본. 돌아간 방향이 한눈에 보이도록 세로로 길다
$receipt:
linear-gradient($stage-orange 0 0) no-repeat 50% 8% / 64% 7%,
repeating-linear-gradient(0deg, transparent 0 18%, rgba(23, 20, 26, .56) 18% 21%, transparent 21% 26%),
linear-gradient($subject-cream, $subject-cream);
.rf {
display: flex;
align-items: center;
gap: $sp-2;
}
.rf__view {
box-sizing: border-box;
display: grid;
place-items: center;
width: 180px;
height: 152px;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 247, 230, .08);
}
.rf__paper {
width: 84px;
height: 132px;
border-radius: $r-sm;
background: $receipt;
box-shadow: $shadow-press;
transform-origin: center center;
transform: rotate(0deg) scaleX(1);
}
.rf__bar {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.rf__btn {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: $r-control;
background: rgba(255, 247, 230, .14);
color: $color;
cursor: pointer;
}
.rf__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rf__btn[aria-pressed="true"] { background: $stage-yellow; color: $subject-ink; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 0 → 360 도로 한 바퀴를 다 돌아 제자리로 온다. 중간에 좌우를 한 번 뒤집었다가 되돌린다.
// 뒤집기는 2% 구간(40ms)에 끝나므로 24장 미리보기에 납작해진 프레임이 남지 않는다
.rf.is-demo .rf__paper {
animation-name: rf-turn;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes rf-turn {
0% { transform: rotate(0deg) scaleX(1); }
22% { transform: rotate(90deg) scaleX(1); }
44% { transform: rotate(180deg) scaleX(1); }
46% { transform: rotate(180deg) scaleX(-1); }
66% { transform: rotate(270deg) scaleX(-1); }
84% { transform: rotate(360deg) scaleX(-1); }
86% { transform: rotate(360deg) scaleX(1); }
100% { transform: rotate(360deg) scaleX(1); }
}
@media (max-width: 360px) {
.rf__view { width: 160px; height: 144px; }
.rf__paper { width: 76px; height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.rf.is-demo .rf__paper { animation: none !important; transform: rotate(90deg) scaleX(1); }
}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);
}
.rf {
display: flex;
align-items: center;
gap: 8px;
}
.rf__view {
box-sizing: border-box;
display: grid;
place-items: center;
width: 180px;
height: 152px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.08);
}
.rf__paper {
width: 84px;
height: 132px;
border-radius: 8px;
background: linear-gradient(#ff4d1f 0 0) no-repeat 50% 8%/64% 7%, repeating-linear-gradient(0deg, transparent 0 18%, rgba(23, 20, 26, 0.56) 18% 21%, transparent 21% 26%), linear-gradient(#fff7e6, #fff7e6);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform-origin: center center;
transform: rotate(0deg) scaleX(1);
}
.rf__bar {
display: flex;
flex-direction: column;
gap: 8px;
}
.rf__btn {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.14);
color: #fff7e6;
cursor: pointer;
}
.rf__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.rf__btn[aria-pressed=true] {
background: #ffd23f;
color: #17141a;
}
.rf.is-demo .rf__paper {
animation-name: rf-turn;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes rf-turn {
0% {
transform: rotate(0deg) scaleX(1);
}
22% {
transform: rotate(90deg) scaleX(1);
}
44% {
transform: rotate(180deg) scaleX(1);
}
46% {
transform: rotate(180deg) scaleX(-1);
}
66% {
transform: rotate(270deg) scaleX(-1);
}
84% {
transform: rotate(360deg) scaleX(-1);
}
86% {
transform: rotate(360deg) scaleX(1);
}
100% {
transform: rotate(360deg) scaleX(1);
}
}
@media (max-width: 360px) {
.rf__view {
width: 160px;
height: 144px;
}
.rf__paper {
width: 76px;
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.rf.is-demo .rf__paper {
animation: none !important;
transform: rotate(90deg) scaleX(1);
}
}// 실제 동작: 각도는 90도씩 더하고, 뒤집기는 scaleX 의 부호만 바꾼다.
var rf = document.getElementById('rf');
var rfPaper = document.getElementById('rf-paper');
var rfFlip = document.getElementById('rf-flip');
var rfAngle = 0, rfMirror = 1;
function rfPaint() {
rf.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
rfPaper.style.transform = 'rotate(' + rfAngle + 'deg) scaleX(' + rfMirror + ')';
rfFlip.setAttribute('aria-pressed', rfMirror === -1 ? 'true' : 'false');
}
document.getElementById('rf-turn').addEventListener('click', function () { rfAngle += 90; rfPaint(); });
rfFlip.addEventListener('click', function () { rfMirror = -rfMirror; rfPaint(); });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RotateFlipToolbar.module.scss";
export type RotateFlipToolbarProps = {
paperLabel?: string;
turnLabel?: string;
flipLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const Turn = () => (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M240 56v48a8 8 0 0 1-8 8h-48a8 8 0 0 1 0-16h27.4l-26.59-24.36l-.25-.24a80 80 0 1 0-1.67 114.78a8 8 0 0 1 11 11.63A95.44 95.44 0 0 1 128 224h-1.32a96 96 0 1 1 69.07-164L224 85.8V56a8 8 0 1 1 16 0" />
</g>
</svg>
);
const Flip = () => (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M112 40v160a8 8 0 0 1-8 8H40a8 8 0 0 1-7.37-11.12l64-160C100 28.86 112 31.29 112 40" opacity=".2" />
<path d="M107.18 24.33a15.86 15.86 0 0 0-17.92 9.45l-.06.14l-64 159.93A16 16 0 0 0 40 216h64a16 16 0 0 0 16-16V40a15.85 15.85 0 0 0-12.82-15.67M104 200H40l.06-.15L104 40Zm126.77-6.15l-64-159.93l-.06-.14A16 16 0 0 0 136 40v160a16 16 0 0 0 16 16h64a16 16 0 0 0 14.78-22.15ZM152 200V40l63.93 159.84l.06.15Z" />
</g>
</svg>
);
export default function RotateFlipToolbar({
paperLabel = "Receipt scan captured sideways",
turnLabel = "Rotate 90 degrees",
flipLabel = "Flip horizontally",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: RotateFlipToolbarProps) {
const [live, setLive] = useState(demo);
const [angle, setAngle] = useState(0);
const [mirror, setMirror] = useState(1);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const paper: CSSProperties = { transform: `rotate(${angle}deg) scaleX(${mirror})` };
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.view}>
<div className={styles.paper} role="img" aria-label={paperLabel} style={live ? undefined : paper} />
</div>
<div className={styles.bar}>
<button
className={styles.btn}
type="button"
aria-label={turnLabel}
onClick={() => { setLive(false); setAngle(angle + 90); }}
>
<Turn />
</button>
<button
className={styles.btn}
type="button"
aria-pressed={mirror === -1}
aria-label={flipLabel}
onClick={() => { setLive(false); setMirror(-mirror); }}
>
<Flip />
</button>
</div>
</div>
);
}05 · 돌리고 뒤집는 툴바 — 측정 누적면적 11.597% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
뒤집기는 scaleX 의 부호를 바꾸는 것이 전부이고, 그 상태를 aria-pressed 에 적어 두어 색을 못 보는 사람도 지금 뒤집혀 있다는 것을 알 수 있습니다.
var rfAngle = 0, rfMirror = 1;
function rfPaint() {
rfPaper.style.transform = 'rotate(' + rfAngle + 'deg) scaleX(' + rfMirror + ')';
rfFlip.setAttribute('aria-pressed', rfMirror === -1 ? 'true' : 'false');
}
document.getElementById('rf-turn').addEventListener('click', function () {
rfAngle += 90;
rfPaint();
});
rfFlip.addEventListener('click', function () {
rfMirror = -rfMirror;
rfPaint();
});
06썸네일 자리에서 자라나는 라이트박스
어떤 움직임인가
큰 판을 화면 한가운데에 그려 놓고, 눌린 썸네일과의 차이만큼 뒤로 되돌려 붙인 다음 그 되돌림을 풀어 줍니다. 되돌리는 동안에는 transition 을 잠시 꺼야 하고, 다시 켜는 시점은 requestAnimationFrame 한 번 뒤입니다.

<main class="stage">
<div class="lb is-demo" id="lb" data-i="2">
<div class="lb__grid">
<button class="lb__t lb__t--1" type="button" data-open="1" aria-label="첫 번째 사진 크게 보기"></button>
<button class="lb__t lb__t--2" type="button" data-open="2" aria-label="두 번째 사진 크게 보기"></button>
<button class="lb__t lb__t--3" type="button" data-open="3" aria-label="세 번째 사진 크게 보기"></button>
</div>
<div class="lb__sheet" role="dialog" aria-modal="true" aria-label="사진 크게 보기">
<div class="lb__scrim" id="lb-scrim"></div>
<div class="lb__panel" id="lb-panel">
<button class="lb__x" id="lb-x" type="button" aria-label="닫기"><svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg></button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// 가짜 사진 세 장 — 파일 없이 그라디언트로만 그렸다
$p1: radial-gradient(circle at 30% 28%, $stage-yellow 0 16%, transparent 17%), linear-gradient(150deg, $stage-orange, $subject-pink 62%, $subject-lilac);
$p2: radial-gradient(circle at 72% 70%, $subject-cream 0 12%, transparent 13%), linear-gradient(28deg, $subject-blue, $subject-mint 70%, $stage-yellow);
$p3: repeating-linear-gradient(120deg, rgba(255, 247, 230, .3) 0 6%, transparent 6% 14%), linear-gradient(200deg, $stage-ink, $subject-sky);
.lb {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
}
.lb__grid {
display: flex;
justify-content: center;
gap: $sp-2;
padding-top: $sp-4;
}
.lb__t {
width: 84px;
height: 64px;
padding: 0;
border: 0;
border-radius: $r-sm;
box-shadow: $shadow-press;
cursor: zoom-in;
}
.lb__t:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.lb__t--1 { background: $p1; }
.lb__t--2 { background: $p2; }
.lb__t--3 { background: $p3; }
// ── 열린 판 ───────────────────────────────────────────
.lb__sheet {
position: absolute;
inset: 0;
display: none;
place-items: center;
}
// 루프 중에도 판은 보이지만 클릭은 통과시킨다 — 안 그러면 스크림이 썸네일을 덮어
// 사람이 열 수가 없다 (Playwright 실측: 클릭이 .lb__scrim 에 가로막혔다)
.lb.is-demo .lb__sheet { display: grid; pointer-events: none; }
.lb.is-open .lb__sheet { display: grid; pointer-events: auto; }
.lb__scrim {
position: absolute;
inset: 0;
border-radius: $r-card;
background: rgba(23, 20, 26, .72);
}
.lb__panel {
position: relative;
box-sizing: border-box;
width: 240px;
height: 120px;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: center center;
transition: transform $dur-enter $easing;
}
.lb[data-i="1"] .lb__panel { background: $p1; }
.lb[data-i="2"] .lb__panel { background: $p2; }
.lb[data-i="3"] .lb__panel { background: $p3; }
.lb__x {
position: absolute;
right: $sp-2;
top: $sp-2;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, .72);
color: $subject-cream;
cursor: pointer;
}
.lb__x:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프 (2s) ────────────────────────────────────
// 두 번째 썸네일 자리(0, -36px · 0.35×0.53배)에서 자라나 제자리로 돌아간다.
// 나타나고 사라지는 순간은 steps 없이도 1% 구간이라 반투명 유령 프레임이 남지 않는다
.lb.is-demo .lb__panel {
transition: none;
animation-name: lb-panel;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lb.is-demo .lb__scrim {
animation-name: lb-scrim;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes lb-panel {
0% { opacity: 0; transform: translate(0, -36px) scale(.35, .533); }
9% { opacity: 0; transform: translate(0, -36px) scale(.35, .533); }
10% { opacity: 1; transform: translate(0, -36px) scale(.35, .533); }
40% { opacity: 1; transform: translate(0, 0) scale(1, 1); }
70% { opacity: 1; transform: translate(0, 0) scale(1, 1); }
86% { opacity: 1; transform: translate(0, -36px) scale(.35, .533); }
87% { opacity: 0; transform: translate(0, -36px) scale(.35, .533); }
100% { opacity: 0; transform: translate(0, -36px) scale(.35, .533); }
}
@keyframes lb-scrim {
0% { opacity: 0; }
10% { opacity: 0; }
22% { opacity: 1; }
78% { opacity: 1; }
87% { opacity: 0; }
100% { opacity: 0; }
}
@media (max-width: 360px) {
.lb { height: 160px; }
.lb__grid { padding-top: $sp-2; }
.lb__panel { width: 224px; height: 112px; }
}
@media (prefers-reduced-motion: reduce) {
.lb.is-demo .lb__panel,
.lb.is-demo .lb__scrim { animation: none !important; }
.lb.is-demo .lb__panel { opacity: 1; transform: translate(0, 0) scale(1, 1); }
.lb.is-demo .lb__scrim { opacity: 1; }
.lb__panel { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lb {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
}
.lb__grid {
display: flex;
justify-content: center;
gap: 8px;
padding-top: 16px;
}
.lb__t {
width: 84px;
height: 64px;
padding: 0;
border: 0;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: zoom-in;
}
.lb__t:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.lb__t--1 {
background: radial-gradient(circle at 30% 28%, #ffd23f 0 16%, transparent 17%), linear-gradient(150deg, #ff4d1f, #ff9ebb 62%, #b9a0ff);
}
.lb__t--2 {
background: radial-gradient(circle at 72% 70%, #fff7e6 0 12%, transparent 13%), linear-gradient(28deg, #2f6df6, #4cd4a6 70%, #ffd23f);
}
.lb__t--3 {
background: repeating-linear-gradient(120deg, rgba(255, 247, 230, 0.3) 0 6%, transparent 6% 14%), linear-gradient(200deg, #17141a, #6ec8ff);
}
.lb__sheet {
position: absolute;
inset: 0;
display: none;
place-items: center;
}
.lb.is-demo .lb__sheet {
display: grid;
pointer-events: none;
}
.lb.is-open .lb__sheet {
display: grid;
pointer-events: auto;
}
.lb__scrim {
position: absolute;
inset: 0;
border-radius: 16px;
background: rgba(23, 20, 26, 0.72);
}
.lb__panel {
position: relative;
box-sizing: border-box;
width: 240px;
height: 120px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: center center;
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lb[data-i="1"] .lb__panel {
background: radial-gradient(circle at 30% 28%, #ffd23f 0 16%, transparent 17%), linear-gradient(150deg, #ff4d1f, #ff9ebb 62%, #b9a0ff);
}
.lb[data-i="2"] .lb__panel {
background: radial-gradient(circle at 72% 70%, #fff7e6 0 12%, transparent 13%), linear-gradient(28deg, #2f6df6, #4cd4a6 70%, #ffd23f);
}
.lb[data-i="3"] .lb__panel {
background: repeating-linear-gradient(120deg, rgba(255, 247, 230, 0.3) 0 6%, transparent 6% 14%), linear-gradient(200deg, #17141a, #6ec8ff);
}
.lb__x {
position: absolute;
right: 8px;
top: 8px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(23, 20, 26, 0.72);
color: #fff7e6;
cursor: pointer;
}
.lb__x:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.lb.is-demo .lb__panel {
transition: none;
animation-name: lb-panel;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lb.is-demo .lb__scrim {
animation-name: lb-scrim;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes lb-panel {
0% {
opacity: 0;
transform: translate(0, -36px) scale(0.35, 0.533);
}
9% {
opacity: 0;
transform: translate(0, -36px) scale(0.35, 0.533);
}
10% {
opacity: 1;
transform: translate(0, -36px) scale(0.35, 0.533);
}
40% {
opacity: 1;
transform: translate(0, 0) scale(1, 1);
}
70% {
opacity: 1;
transform: translate(0, 0) scale(1, 1);
}
86% {
opacity: 1;
transform: translate(0, -36px) scale(0.35, 0.533);
}
87% {
opacity: 0;
transform: translate(0, -36px) scale(0.35, 0.533);
}
100% {
opacity: 0;
transform: translate(0, -36px) scale(0.35, 0.533);
}
}
@keyframes lb-scrim {
0% {
opacity: 0;
}
10% {
opacity: 0;
}
22% {
opacity: 1;
}
78% {
opacity: 1;
}
87% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.lb {
height: 160px;
}
.lb__grid {
padding-top: 8px;
}
.lb__panel {
width: 224px;
height: 112px;
}
}
@media (prefers-reduced-motion: reduce) {
.lb.is-demo .lb__panel,
.lb.is-demo .lb__scrim {
animation: none !important;
}
.lb.is-demo .lb__panel {
opacity: 1;
transform: translate(0, 0) scale(1, 1);
}
.lb.is-demo .lb__scrim {
opacity: 1;
}
.lb__panel {
transition: none;
}
}// 실제 동작: 눌린 썸네일 자리에서 큰 판이 자라난다 (FLIP — 재고, 되돌리고, 한 프레임 뒤 푼다).
var lb = document.getElementById('lb');
var lbPanel = document.getElementById('lb-panel');
var lbThumbs = [].slice.call(lb.querySelectorAll('.lb__t'));
function lbOpen(i) {
lb.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
lb.setAttribute('data-i', String(i));
lb.classList.add('is-open');
var from = lbThumbs[i - 1].getBoundingClientRect();
var to = lbPanel.getBoundingClientRect();
lbPanel.style.transition = 'none';
lbPanel.style.transform = 'translate(' + (from.left - to.left + (from.width - to.width) / 2) + 'px,'
+ (from.top - to.top + (from.height - to.height) / 2) + 'px) scale('
+ (from.width / to.width) + ',' + (from.height / to.height) + ')';
requestAnimationFrame(function () {
lbPanel.style.transition = '';
lbPanel.style.transform = '';
document.getElementById('lb-x').focus({ preventScroll: true });
});
}
function lbClose() {
lb.classList.remove('is-open');
lbThumbs[Number(lb.getAttribute('data-i')) - 1].focus({ preventScroll: true });
}
lbThumbs.forEach(function (t) { t.addEventListener('click', function () { lbOpen(Number(t.getAttribute('data-open'))); }); });
document.getElementById('lb-x').addEventListener('click', lbClose);
document.getElementById('lb-scrim').addEventListener('click', lbClose);
document.addEventListener('keydown', function (e) {
if (!lb.classList.contains('is-open')) return;
if (e.key === 'Escape') { e.preventDefault(); lbClose(); }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
var n = Number(lb.getAttribute('data-i')) + (e.key === 'ArrowRight' ? 1 : -1);
lb.setAttribute('data-i', String(n < 1 ? 3 : n > 3 ? 1 : n));
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LightboxFlip.module.scss";
export type LightboxFlipProps = {
thumbLabels?: string[];
dialogLabel?: string;
closeLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const Close = () => (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" />
</g>
</svg>
);
export default function LightboxFlip({
thumbLabels = ["Open the first photo", "Open the second photo", "Open the third photo"],
dialogLabel = "Photo viewer",
closeLabel = "Close",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LightboxFlipProps) {
const thumbsRef = useRef<HTMLButtonElement[]>([]);
const thumbs: HTMLButtonElement[] = thumbsRef.current || (thumbsRef.current = []);
const panel = useRef<HTMLDivElement>(null);
const closeBtn = useRef<HTMLButtonElement>(null);
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(2);
// FLIP — 두 자리를 재고, 차이만큼 되돌려 놓고, 한 프레임 뒤에 푼다
function flipFrom(i: number) {
const from = thumbs[i - 1] ? thumbs[i - 1].getBoundingClientRect() : null;
const to = panel.current ? panel.current.getBoundingClientRect() : null;
if (!from || !to || !panel.current) return;
panel.current.style.transition = "none";
panel.current.style.transform =
`translate(${from.left - to.left + (from.width - to.width) / 2}px,` +
`${from.top - to.top + (from.height - to.height) / 2}px) ` +
`scale(${from.width / to.width},${from.height / to.height})`;
requestAnimationFrame(() => {
if (!panel.current) return;
panel.current.style.transition = "";
panel.current.style.transform = "";
if (closeBtn.current) closeBtn.current.focus({ preventScroll: true });
});
}
function openAt(i: number) {
setLive(false);
setIndex(i);
setOpen(true);
flipFrom(i);
}
function close() {
setOpen(false);
const t = thumbs[index - 1];
if (t) t.focus({ preventScroll: true });
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.root, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ");
return (
<div
className={cls}
style={vars}
data-i={index}
onKeyDown={(e: KeyEvt) => {
if (!open) return;
if (e.key === "Escape") { e.preventDefault(); close(); }
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
const n = index + (e.key === "ArrowRight" ? 1 : -1);
setIndex(n < 1 ? 3 : n > 3 ? 1 : n);
}
}}
>
<div className={styles.grid}>
{[1, 2, 3].map((i) => (
<button
key={i}
className={[styles.t, i === 1 ? styles.t1 : i === 2 ? styles.t2 : styles.t3].join(" ")}
type="button"
aria-label={thumbLabels[i - 1]}
ref={(el: HTMLButtonElement) => { thumbs[i - 1] = el; }}
onClick={() => openAt(i)}
/>
))}
</div>
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label={dialogLabel}>
<div className={styles.scrim} onClick={close} />
<div className={styles.panel} ref={panel}>
<button className={styles.x} type="button" aria-label={closeLabel} ref={closeBtn} onClick={close}>
<Close />
</button>
</div>
</div>
</div>
);
}06 · 썸네일 자리에서 자라나는 라이트박스 — 측정 누적면적 33.357% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
같은 프레임 안에서 껐다 켜면 브라우저가 두 값을 한 번에 계산해 애니메이션이 통째로 사라집니다.
function lbOpen(i) {
lb.setAttribute('data-i', String(i));
lb.classList.add('is-open');
var from = lbThumbs[i - 1].getBoundingClientRect();
var to = lbPanel.getBoundingClientRect();
lbPanel.style.transition = 'none';
lbPanel.style.transform = 'translate(' + (from.left - to.left + (from.width - to.width) / 2) + 'px,'
+ (from.top - to.top + (from.height - to.height) / 2) + 'px) scale('
+ (from.width / to.width) + ',' + (from.height / to.height) + ')';
requestAnimationFrame(function () {
lbPanel.style.transition = '';
lbPanel.style.transform = '';
document.getElementById('lb-x').focus({ preventScroll: true });
});
}
07끌어서 한 바퀴 돌리는 360도 뷰
무엇이 보이나
열두 칸이 한 줄로 붙어 있고 창은 한 칸만 보여 줍니다. 끌어 옮긴 거리를 18로 나눠 반올림한 값이 칸 번호가 되고, 나머지 연산을 두 번 겹쳐 음수까지 0에서 11 사이로 접습니다.

<main class="stage">
<div class="sp is-demo" id="sp">
<div class="sp__win" id="sp-win" tabindex="0" role="img" aria-label="머그컵 360도 보기. 화살표 키로 돌립니다">
<div class="sp__track" id="sp-track">
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
<div class="sp__f"><i class="sp__handle"></i><i class="sp__body"></i><i class="sp__logo"></i></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.sp { position: relative; }
.sp__win {
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: linear-gradient($stage-ink, #2f2838);
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
}
.sp__win:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
// 열두 칸이 한 줄로 붙어 있고 창은 한 칸만 보여 준다. gap 은 0 — 있으면 한 바퀴마다 어긋난다
.sp__track {
display: flex;
width: 1200%;
height: 100%;
transform: translateX(0);
}
.sp__f {
position: relative;
flex: 0 0 8.3333%;
height: 100%;
display: grid;
place-items: center;
}
.sp__f > i {
position: absolute;
left: 50%;
top: 50%;
display: block;
}
// 가짜 사진 — 파일 없이 그린 머그컵. 손잡이와 로고가 각도마다 자리를 바꾼다
.sp__body {
width: 92px;
height: 108px;
margin-left: -46px;
margin-top: -54px;
border-radius: $r-control $r-control $r-card $r-card;
background: linear-gradient(90deg, #a96953 0 14%, $stage-orange 38%, $subject-pink 58%, $error 100%);
box-shadow: inset 0 -12px 20px rgba(23, 20, 26, .45);
z-index: 1;
}
.sp__handle {
width: 44px;
height: 56px;
margin-left: -22px;
margin-top: -24px;
border: 10px solid #b98272;
border-radius: 50%;
transform: translateX(calc(44px * var(--sin))) scaleX(var(--hs));
z-index: var(--hz);
}
.sp__logo {
width: 36px;
height: 36px;
margin-left: -18px;
margin-top: -16px;
border-radius: 50%;
background: $stage-yellow;
opacity: var(--lo);
transform: translateX(calc(-44px * var(--sin))) scaleX(var(--ls));
z-index: 2;
}
// 30도씩 열두 각도. --sin 은 손잡이 가로 위치(반지름 44px 에 곱한다),
// --hs 는 옆으로 뻗은 손잡이가 정면을 볼수록 납작해지는 정도, --ls 는 컵에 찍힌 로고가 돌아가며 눌리는 정도,
// --hz 는 손잡이가 컵 앞(2)인지 뒤(0)인지, --lo 는 로고가 이쪽을 보는지.
// 첫 칸을 60도로 잡아, 루프를 끈 정지 화면에서도 손잡이가 보이는 3/4 각도로 선다
.sp__f:nth-child(1) { --sin: .87; --hs: .87; --ls: .5; --hz: 2; --lo: 0; }
.sp__f:nth-child(2) { --sin: 1; --hs: 1; --ls: 0; --hz: 2; --lo: 0; }
.sp__f:nth-child(3) { --sin: .87; --hs: .87; --ls: .5; --hz: 0; --lo: 1; }
.sp__f:nth-child(4) { --sin: .5; --hs: .5; --ls: .87; --hz: 0; --lo: 1; }
.sp__f:nth-child(5) { --sin: 0; --hs: .06; --ls: 1; --hz: 0; --lo: 1; }
.sp__f:nth-child(6) { --sin: -.5; --hs: .5; --ls: .87; --hz: 0; --lo: 1; }
.sp__f:nth-child(7) { --sin: -.87; --hs: .87; --ls: .5; --hz: 0; --lo: 1; }
.sp__f:nth-child(8) { --sin: -1; --hs: 1; --ls: 0; --hz: 2; --lo: 0; }
.sp__f:nth-child(9) { --sin: -.87; --hs: .87; --ls: .5; --hz: 2; --lo: 0; }
.sp__f:nth-child(10) { --sin: -.5; --hs: .5; --ls: .87; --hz: 2; --lo: 0; }
.sp__f:nth-child(11) { --sin: 0; --hs: .06; --ls: 1; --hz: 2; --lo: 0; }
.sp__f:nth-child(12) { --sin: .5; --hs: .5; --ls: .87; --hz: 2; --lo: 0; }
// ── 자동 루프 (2s) ────────────────────────────────────
// steps(12, end) 가 0/12 부터 11/12 까지 열두 칸을 정확히 밟는다.
// 열두 번째 다음이 첫 장이고 그 둘은 이어지는 각도라 한 바퀴가 매끄럽다
.sp.is-demo .sp__track {
animation-name: sp-spin;
animation-duration: $dur-loop;
animation-timing-function: steps(12, end);
animation-iteration-count: infinite;
}
@keyframes sp-spin {
0% { transform: translateX(0); }
100% { transform: translateX(-100%); }
}
@media (max-width: 360px) {
.sp__win { height: 160px; }
.sp__body { width: 84px; height: 100px; margin-left: -42px; margin-top: -50px; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__track { animation: none !important; transform: translateX(-25%); }
}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);
}
.sp {
position: relative;
}
.sp__win {
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: linear-gradient(#17141a, #2f2838);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
}
.sp__win:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.sp__track {
display: flex;
width: 1200%;
height: 100%;
transform: translateX(0);
}
.sp__f {
position: relative;
flex: 0 0 8.3333%;
height: 100%;
display: grid;
place-items: center;
}
.sp__f > i {
position: absolute;
left: 50%;
top: 50%;
display: block;
}
.sp__body {
width: 92px;
height: 108px;
margin-left: -46px;
margin-top: -54px;
border-radius: 12px 12px 16px 16px;
background: linear-gradient(90deg, #a96953 0 14%, #ff4d1f 38%, #ff9ebb 58%, #c2361a 100%);
box-shadow: inset 0 -12px 20px rgba(23, 20, 26, 0.45);
z-index: 1;
}
.sp__handle {
width: 44px;
height: 56px;
margin-left: -22px;
margin-top: -24px;
border: 10px solid #b98272;
border-radius: 50%;
transform: translateX(calc(44px * var(--sin))) scaleX(var(--hs));
z-index: var(--hz);
}
.sp__logo {
width: 36px;
height: 36px;
margin-left: -18px;
margin-top: -16px;
border-radius: 50%;
background: #ffd23f;
opacity: var(--lo);
transform: translateX(calc(-44px * var(--sin))) scaleX(var(--ls));
z-index: 2;
}
.sp__f:nth-child(1) {
--sin: .87;
--hs: .87;
--ls: .5;
--hz: 2;
--lo: 0;
}
.sp__f:nth-child(2) {
--sin: 1;
--hs: 1;
--ls: 0;
--hz: 2;
--lo: 0;
}
.sp__f:nth-child(3) {
--sin: .87;
--hs: .87;
--ls: .5;
--hz: 0;
--lo: 1;
}
.sp__f:nth-child(4) {
--sin: .5;
--hs: .5;
--ls: .87;
--hz: 0;
--lo: 1;
}
.sp__f:nth-child(5) {
--sin: 0;
--hs: .06;
--ls: 1;
--hz: 0;
--lo: 1;
}
.sp__f:nth-child(6) {
--sin: -.5;
--hs: .5;
--ls: .87;
--hz: 0;
--lo: 1;
}
.sp__f:nth-child(7) {
--sin: -.87;
--hs: .87;
--ls: .5;
--hz: 0;
--lo: 1;
}
.sp__f:nth-child(8) {
--sin: -1;
--hs: 1;
--ls: 0;
--hz: 2;
--lo: 0;
}
.sp__f:nth-child(9) {
--sin: -.87;
--hs: .87;
--ls: .5;
--hz: 2;
--lo: 0;
}
.sp__f:nth-child(10) {
--sin: -.5;
--hs: .5;
--ls: .87;
--hz: 2;
--lo: 0;
}
.sp__f:nth-child(11) {
--sin: 0;
--hs: .06;
--ls: 1;
--hz: 2;
--lo: 0;
}
.sp__f:nth-child(12) {
--sin: .5;
--hs: .5;
--ls: .87;
--hz: 2;
--lo: 0;
}
.sp.is-demo .sp__track {
animation-name: sp-spin;
animation-duration: 2s;
animation-timing-function: steps(12, end);
animation-iteration-count: infinite;
}
@keyframes sp-spin {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
@media (max-width: 360px) {
.sp__win {
height: 160px;
}
.sp__body {
width: 84px;
height: 100px;
margin-left: -42px;
margin-top: -50px;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__track {
animation: none !important;
transform: translateX(-25%);
}
}// 실제 동작: 끈 거리를 프레임 번호로 바꾼다. 열두 장이 한 바퀴다.
var sp = document.getElementById('sp');
var spWin = document.getElementById('sp-win');
var spTrack = document.getElementById('sp-track');
var SP_N = 12, SP_STEP = 18;
var spI = 0, spDrag = null;
function spPaint() {
sp.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
spI = ((spI % SP_N) + SP_N) % SP_N;
spTrack.style.transform = 'translateX(' + (-spI * 100 / SP_N) + '%)';
}
spWin.addEventListener('pointerdown', function (e) {
spDrag = { x: e.clientX, i: spI };
spWin.setPointerCapture(e.pointerId);
});
spWin.addEventListener('pointermove', function (e) {
if (!spDrag) return;
spI = spDrag.i - Math.round((e.clientX - spDrag.x) / SP_STEP);
spPaint();
});
spWin.addEventListener('pointerup', function () { spDrag = null; });
spWin.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
spI += e.key === 'ArrowRight' ? 1 : -1;
spPaint();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpinView360.module.scss";
export type SpinView360Props = {
label?: string;
/** 한 바퀴를 이루는 칸 수. module.scss 의 steps(N, end) 와 같아야 한다 */
frames?: number;
/** 한 칸을 넘기는 데 필요한 드래그 거리(px) */
dragStep?: number;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type DragEvt = { clientX: number; pointerId: number };
type KeyEvt = { key: string; preventDefault: () => void };
export default function SpinView360({
label = "Mug in 360 degrees. Turn it with the arrow keys",
frames = 12,
dragStep = 18,
demo = true,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: SpinView360Props) {
const drag = useRef<{ x: number; i: number } | null>(null);
const [live, setLive] = useState(demo);
const [i, setI] = useState(0);
function go(next: number) {
setLive(false);
setI(((next % frames) + frames) % frames);
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const track: CSSProperties = { transform: `translateX(${(-i * 100) / frames}%)` };
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div
className={styles.win}
tabIndex={0}
role="img"
aria-label={label}
onPointerDown={(e: DragEvt) => { drag.current = { x: e.clientX, i }; }}
onPointerMove={(e: DragEvt) => {
const d = drag.current;
if (!d) return;
go(d.i - Math.round((e.clientX - d.x) / dragStep));
}}
onPointerUp={() => { drag.current = null; }}
onKeyDown={(e: KeyEvt) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
go(i + (e.key === "ArrowRight" ? 1 : -1));
}}
>
<div className={styles.track} style={live ? undefined : track}>
{Array.from({ length: frames }, (_, k) => (
<div className={styles.f} key={k}>
<i className={styles.handle} />
<i className={styles.body} />
<i className={styles.logo} />
</div>
))}
</div>
</div>
</div>
);
}07 · 끌어서 한 바퀴 돌리는 360도 뷰 — 측정 누적면적 5.815% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
손잡이의 가로 위치와 눌린 정도는 칸마다 적어 둔 사인 값에서, 컵에 찍힌 로고가 눌리는 정도는 코사인 값에서 나오므로, 진짜 사진 열두 장 없이도 한 바퀴가 됩니다.
var SP_N = 12, SP_STEP = 18;
function spPaint() {
spI = ((spI % SP_N) + SP_N) % SP_N;
spTrack.style.transform = 'translateX(' + (-spI * 100 / SP_N) + '%)';
}
spWin.addEventListener('pointerdown', function (e) {
spDrag = { x: e.clientX, i: spI };
spWin.setPointerCapture(e.pointerId);
});
spWin.addEventListener('pointermove', function (e) {
if (!spDrag) return;
spI = spDrag.i - Math.round((e.clientX - spDrag.x) / SP_STEP);
spPaint();
});
08포인터를 따라 켜지는 스포트라이트
화면에서 일어나는 일
같은 그림을 두 겹 깔고 아래 겹만 회색으로 어둡게 만듭니다. 위 겹은 mask-image 에 넣은 원 안에서만 남으므로, 원의 자리인 mask-position 만 옮기면 밝은 곳이 따라옵니다.

<main class="stage">
<div class="sl is-demo" id="sl">
<div class="sl__view" id="sl-view" tabindex="0" role="img" aria-label="단체 사진. 화살표 키로 조명을 옮깁니다">
<div class="sl__dim"></div>
<div class="sl__lit" id="sl-lit"></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
// 가짜 사진 — 파일 없이 그린 단체 사진. 머리 여섯 개가 줄지어 서 있다
$crowd:
radial-gradient(circle at 14% 62%, $subject-pink 0 8%, transparent 9%),
radial-gradient(circle at 31% 54%, $subject-cream 0 8%, transparent 9%),
radial-gradient(circle at 48% 64%, $subject-mint 0 8%, transparent 9%),
radial-gradient(circle at 65% 52%, $stage-yellow 0 8%, transparent 9%),
radial-gradient(circle at 82% 62%, $subject-lilac 0 8%, transparent 9%),
radial-gradient(circle at 50% 18%, $subject-sky 0 22%, transparent 23%),
linear-gradient(170deg, #2b2540 0%, $stage-ink 100%);
.sl { position: relative; }
.sl__view {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $stage-ink;
box-shadow: $shadow-raised;
cursor: crosshair;
}
.sl__view:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.sl__dim,
.sl__lit {
position: absolute;
inset: 0;
background: $crowd;
background-size: 288px 168px;
}
.sl__dim { filter: grayscale(1) brightness(.44); }
// 선명한 겹은 radial-gradient 로 오린 자리에서만 보인다
.sl__lit {
-webkit-mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 128px 128px;
mask-size: 128px 128px;
-webkit-mask-position: 12px -6px;
mask-position: 12px -6px;
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 조명이 세 사람을 훑고 첫 자리로 돌아온다. 끝값이 시작값이라 이음매가 없다
.sl.is-demo .sl__lit {
animation-name: sl-move;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes sl-move {
0% { -webkit-mask-position: 12px -6px; mask-position: 12px -6px; }
34% { -webkit-mask-position: 132px 40px; mask-position: 132px 40px; }
68% { -webkit-mask-position: 56px 68px; mask-position: 56px 68px; }
100% { -webkit-mask-position: 12px -6px; mask-position: 12px -6px; }
}
@media (max-width: 360px) {
.sl__view { height: 160px; }
}
@media (prefers-reduced-motion: reduce) {
.sl.is-demo .sl__lit { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sl {
position: relative;
}
.sl__view {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: crosshair;
}
.sl__view:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.sl__dim,
.sl__lit {
position: absolute;
inset: 0;
background: radial-gradient(circle at 14% 62%, #ff9ebb 0 8%, transparent 9%), radial-gradient(circle at 31% 54%, #fff7e6 0 8%, transparent 9%), radial-gradient(circle at 48% 64%, #4cd4a6 0 8%, transparent 9%), radial-gradient(circle at 65% 52%, #ffd23f 0 8%, transparent 9%), radial-gradient(circle at 82% 62%, #b9a0ff 0 8%, transparent 9%), radial-gradient(circle at 50% 18%, #6ec8ff 0 22%, transparent 23%), linear-gradient(170deg, #2b2540 0%, #17141a 100%);
background-size: 288px 168px;
}
.sl__dim {
filter: grayscale(1) brightness(0.44);
}
.sl__lit {
-webkit-mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 128px 128px;
mask-size: 128px 128px;
-webkit-mask-position: 12px -6px;
mask-position: 12px -6px;
}
.sl.is-demo .sl__lit {
animation-name: sl-move;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes sl-move {
0% {
-webkit-mask-position: 12px -6px;
mask-position: 12px -6px;
}
34% {
-webkit-mask-position: 132px 40px;
mask-position: 132px 40px;
}
68% {
-webkit-mask-position: 56px 68px;
mask-position: 56px 68px;
}
100% {
-webkit-mask-position: 12px -6px;
mask-position: 12px -6px;
}
}
@media (max-width: 360px) {
.sl__view {
height: 160px;
}
}
@media (prefers-reduced-motion: reduce) {
.sl.is-demo .sl__lit {
animation: none !important;
}
}// 실제 동작: 선명한 겹을 원형 마스크로 오려 보여 준다. 원이 가는 자리만 밝아진다.
var sl = document.getElementById('sl');
var slView = document.getElementById('sl-view');
var slLit = document.getElementById('sl-lit');
var SL_R = 64;
var slX = 76, slY = 58;
function slPaint() {
var box = slView.getBoundingClientRect();
slX = Math.min(Math.max(slX, 0), box.width);
slY = Math.min(Math.max(slY, 0), box.height);
var pos = (slX - SL_R) + 'px ' + (slY - SL_R) + 'px';
slLit.style.maskPosition = pos;
slLit.style.webkitMaskPosition = pos;
}
slView.addEventListener('pointermove', function (e) {
sl.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
var box = slView.getBoundingClientRect();
slX = e.clientX - box.left;
slY = e.clientY - box.top;
slPaint();
});
slView.addEventListener('keydown', function (e) {
var step = { ArrowLeft: [-20, 0], ArrowRight: [20, 0], ArrowUp: [0, -20], ArrowDown: [0, 20] }[e.key];
if (!step) return;
e.preventDefault();
sl.classList.remove('is-demo');
slX += step[0]; slY += step[1];
slPaint();
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpotlightMask.module.scss";
export type SpotlightMaskProps = {
label?: string;
/** 마스크 원의 반지름(px). module.scss 의 mask-size 절반과 같아야 한다 */
radius?: number;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type MaskStyle = CSSProperties & { WebkitMaskPosition?: string; maskPosition?: string };
type PointerEvt = { clientX: number; clientY: 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 SpotlightMask({
label = "Group photo. Move the spotlight with the arrow keys",
radius = 64,
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SpotlightMaskProps) {
const view = useRef<HTMLDivElement>(null);
const [live, setLive] = useState(demo);
const [at, setAt] = useState({ x: 76, y: 58 });
function move(x: number, y: number) {
const box = view.current ? view.current.getBoundingClientRect() : null;
setLive(false);
setAt({ x: clamp(x, 0, box ? box.width : 288), y: clamp(y, 0, box ? box.height : 168) });
}
const pos = `${at.x - radius}px ${at.y - radius}px`;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const lit: MaskStyle = { WebkitMaskPosition: pos, maskPosition: pos };
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div
className={styles.view}
ref={view}
tabIndex={0}
role="img"
aria-label={label}
onPointerMove={(e: PointerEvt) => {
const box = view.current ? view.current.getBoundingClientRect() : { left: 0, top: 0 };
move(e.clientX - box.left, e.clientY - box.top);
}}
onKeyDown={(e: KeyEvt) => {
const step: { [k: string]: number[] } = {
ArrowLeft: [-20, 0], ArrowRight: [20, 0], ArrowUp: [0, -20], ArrowDown: [0, 20],
};
if (!step[e.key]) return;
e.preventDefault();
move(at.x + step[e.key][0], at.y + step[e.key][1]);
}}
>
<div className={styles.dim} />
<div className={styles.lit} style={live ? undefined : lit} />
</div>
</div>
);
}08 · 포인터를 따라 켜지는 스포트라이트 — 측정 누적면적 19.468% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
원의 크기는 mask-size 로 고정해 두었기 때문에 자바스크립트는 좌표 두 개만 계산하면 됩니다.
.sl__lit {
-webkit-mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 128px 128px;
mask-size: 128px 128px;
-webkit-mask-position: 12px -6px;
mask-position: 12px -6px;
}
09사진 위에 꽂힌 설명 핀
어떤 움직임인가
핀은 <div> 가 아니라 <button> 이라 탭 키만으로 세 곳을 돌 수 있습니다. 열고 닫는 상태는 클래스가 아니라 aria-expanded 에 적고, 쪽지를 보여 주는 규칙도 그 속성을 선택자로 씁니다.

<main class="stage">
<div class="hs is-demo" id="hs">
<div class="hs__view" role="img" aria-label="책상 사진">
<div class="hs__spot hs__spot--1">
<button class="hs__pin" type="button" aria-expanded="false" aria-controls="hs-n1" aria-label="상판 설명"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></button>
<span class="hs__note" id="hs-n1">떡갈나무 상판</span>
</div>
<div class="hs__spot hs__spot--2">
<button class="hs__pin" type="button" aria-expanded="false" aria-controls="hs-n2" aria-label="손잡이 설명"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></button>
<span class="hs__note" id="hs-n2">황동 손잡이</span>
</div>
<div class="hs__spot hs__spot--3">
<button class="hs__pin" type="button" aria-expanded="false" aria-controls="hs-n3" aria-label="다리 설명"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12"/></g></svg></button>
<span class="hs__note" id="hs-n3">무광 검정 다리</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-yellow;
@include stage($stage-ink);
// 가짜 사진 — 파일 없이 그린 책상. 상판·손잡이·다리 세 곳에 핀을 꽂는다
$desk:
linear-gradient(90deg, transparent 0 20%, #5f4128 20% 25%, transparent 25% 75%, #5f4128 75% 80%, transparent 80%) no-repeat 0 100% / 100% 28%,
radial-gradient(circle at 58% 66%, #d4c4a7 0 2.6%, transparent 3.4%),
linear-gradient(#9a6739, #6b4526) no-repeat 0 58% / 100% 16%,
radial-gradient(circle at 80% 22%, $subject-lilac 0 10%, transparent 11%),
linear-gradient(200deg, #2b2540, $stage-ink);
.hs { position: relative; }
.hs__view {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $desk;
box-shadow: $shadow-raised;
}
.hs__spot { position: absolute; }
.hs__spot--1 { left: 22%; top: 66%; }
.hs__spot--2 { left: 58%; top: 66%; }
.hs__spot--3 { left: 78%; top: 88%; }
.hs__pin {
position: absolute;
left: -14px;
top: -14px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 2px solid rgba(255, 247, 230, .82);
border-radius: 50%;
background: rgba(23, 20, 26, .78);
color: $subject-cream;
cursor: pointer;
transform: scale(1);
transition: transform $duration $easing, background $duration $easing;
}
.hs__pin:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.hs__pin[aria-expanded="true"] { background: $color; color: $subject-ink; transform: scale(1.16); }
.hs__note {
position: absolute;
left: -60px;
top: 20px;
display: block;
box-sizing: border-box;
width: 120px;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: $subject-cream;
color: $subject-ink;
text-align: center;
font-size: 11px;
line-height: 16px;
font-weight: 700;
opacity: 0;
transform-origin: center top;
transform: scale(.6);
transition: transform $duration $easing, opacity $duration $easing;
pointer-events: none;
}
.hs__spot--3 .hs__note { top: -44px; transform-origin: center bottom; }
.hs__pin[aria-expanded="true"] + .hs__note { opacity: 1; transform: scale(1); }
// ── 자동 루프 (2s) ────────────────────────────────────
// 세 쪽지가 차례로 부풀었다 사라지고 88% 에 모두 닫힌다 — 24장 미리보기의 마지막 장까지 닫힌 뒤다
.hs.is-demo .hs__note {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.hs.is-demo .hs__pin {
transition: none;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hs.is-demo .hs__spot--1 .hs__note { animation-name: hs-n1; }
.hs.is-demo .hs__spot--2 .hs__note { animation-name: hs-n2; }
.hs.is-demo .hs__spot--3 .hs__note { animation-name: hs-n3; }
.hs.is-demo .hs__spot--1 .hs__pin { animation-name: hs-p1; }
.hs.is-demo .hs__spot--2 .hs__pin { animation-name: hs-p2; }
.hs.is-demo .hs__spot--3 .hs__pin { animation-name: hs-p3; }
@keyframes hs-n1 {
0% { opacity: 0; transform: scale(.6); }
6% { opacity: 0; transform: scale(.6); }
7% { opacity: 1; transform: scale(.6); }
16% { opacity: 1; transform: scale(1); }
34% { opacity: 1; transform: scale(1); }
35% { opacity: 0; transform: scale(.6); }
100% { opacity: 0; transform: scale(.6); }
}
@keyframes hs-n2 {
0% { opacity: 0; transform: scale(.6); }
40% { opacity: 0; transform: scale(.6); }
41% { opacity: 1; transform: scale(.6); }
50% { opacity: 1; transform: scale(1); }
66% { opacity: 1; transform: scale(1); }
67% { opacity: 0; transform: scale(.6); }
100% { opacity: 0; transform: scale(.6); }
}
@keyframes hs-n3 {
0% { opacity: 0; transform: scale(.6); }
72% { opacity: 0; transform: scale(.6); }
73% { opacity: 1; transform: scale(.6); }
80% { opacity: 1; transform: scale(1); }
87% { opacity: 1; transform: scale(1); }
88% { opacity: 0; transform: scale(.6); }
100% { opacity: 0; transform: scale(.6); }
}
@keyframes hs-p1 {
0% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
7% { background: $color; color: $subject-ink; transform: scale(1.16); }
35% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
100% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
}
@keyframes hs-p2 {
0% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
41% { background: $color; color: $subject-ink; transform: scale(1.16); }
67% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
100% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
}
@keyframes hs-p3 {
0% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
73% { background: $color; color: $subject-ink; transform: scale(1.16); }
88% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
100% { background: rgba(23, 20, 26, .78); color: $subject-cream; transform: scale(1); }
}
@media (max-width: 360px) {
.hs__view { height: 160px; }
.hs__note { width: 112px; left: -56px; }
}
@media (prefers-reduced-motion: reduce) {
.hs.is-demo .hs__note,
.hs.is-demo .hs__pin { animation: none !important; }
.hs.is-demo .hs__spot--2 .hs__note { opacity: 1; transform: scale(1); }
.hs.is-demo .hs__spot--2 .hs__pin { background: $color; color: $subject-ink; }
}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);
}
.hs {
position: relative;
}
.hs__view {
position: relative;
box-sizing: border-box;
width: 288px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: linear-gradient(90deg, transparent 0 20%, #5f4128 20% 25%, transparent 25% 75%, #5f4128 75% 80%, transparent 80%) no-repeat 0 100%/100% 28%, radial-gradient(circle at 58% 66%, #d4c4a7 0 2.6%, transparent 3.4%), linear-gradient(#9a6739, #6b4526) no-repeat 0 58%/100% 16%, radial-gradient(circle at 80% 22%, #b9a0ff 0 10%, transparent 11%), linear-gradient(200deg, #2b2540, #17141a);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.hs__spot {
position: absolute;
}
.hs__spot--1 {
left: 22%;
top: 66%;
}
.hs__spot--2 {
left: 58%;
top: 66%;
}
.hs__spot--3 {
left: 78%;
top: 88%;
}
.hs__pin {
position: absolute;
left: -14px;
top: -14px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
padding: 0;
border: 2px solid rgba(255, 247, 230, 0.82);
border-radius: 50%;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
cursor: pointer;
transform: scale(1);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.hs__pin:focus-visible {
outline: 3px solid #ffd23f;
outline-offset: 2px;
}
.hs__pin[aria-expanded=true] {
background: #ffd23f;
color: #17141a;
transform: scale(1.16);
}
.hs__note {
position: absolute;
left: -60px;
top: 20px;
display: block;
box-sizing: border-box;
width: 120px;
padding: 4px 8px;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
text-align: center;
font-size: 11px;
line-height: 16px;
font-weight: 700;
opacity: 0;
transform-origin: center top;
transform: scale(0.6);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
pointer-events: none;
}
.hs__spot--3 .hs__note {
top: -44px;
transform-origin: center bottom;
}
.hs__pin[aria-expanded=true] + .hs__note {
opacity: 1;
transform: scale(1);
}
.hs.is-demo .hs__note {
transition: none;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.hs.is-demo .hs__pin {
transition: none;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.hs.is-demo .hs__spot--1 .hs__note {
animation-name: hs-n1;
}
.hs.is-demo .hs__spot--2 .hs__note {
animation-name: hs-n2;
}
.hs.is-demo .hs__spot--3 .hs__note {
animation-name: hs-n3;
}
.hs.is-demo .hs__spot--1 .hs__pin {
animation-name: hs-p1;
}
.hs.is-demo .hs__spot--2 .hs__pin {
animation-name: hs-p2;
}
.hs.is-demo .hs__spot--3 .hs__pin {
animation-name: hs-p3;
}
@keyframes hs-n1 {
0% {
opacity: 0;
transform: scale(0.6);
}
6% {
opacity: 0;
transform: scale(0.6);
}
7% {
opacity: 1;
transform: scale(0.6);
}
16% {
opacity: 1;
transform: scale(1);
}
34% {
opacity: 1;
transform: scale(1);
}
35% {
opacity: 0;
transform: scale(0.6);
}
100% {
opacity: 0;
transform: scale(0.6);
}
}
@keyframes hs-n2 {
0% {
opacity: 0;
transform: scale(0.6);
}
40% {
opacity: 0;
transform: scale(0.6);
}
41% {
opacity: 1;
transform: scale(0.6);
}
50% {
opacity: 1;
transform: scale(1);
}
66% {
opacity: 1;
transform: scale(1);
}
67% {
opacity: 0;
transform: scale(0.6);
}
100% {
opacity: 0;
transform: scale(0.6);
}
}
@keyframes hs-n3 {
0% {
opacity: 0;
transform: scale(0.6);
}
72% {
opacity: 0;
transform: scale(0.6);
}
73% {
opacity: 1;
transform: scale(0.6);
}
80% {
opacity: 1;
transform: scale(1);
}
87% {
opacity: 1;
transform: scale(1);
}
88% {
opacity: 0;
transform: scale(0.6);
}
100% {
opacity: 0;
transform: scale(0.6);
}
}
@keyframes hs-p1 {
0% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
7% {
background: #ffd23f;
color: #17141a;
transform: scale(1.16);
}
35% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
100% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
}
@keyframes hs-p2 {
0% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
41% {
background: #ffd23f;
color: #17141a;
transform: scale(1.16);
}
67% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
100% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
}
@keyframes hs-p3 {
0% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
73% {
background: #ffd23f;
color: #17141a;
transform: scale(1.16);
}
88% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
100% {
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
transform: scale(1);
}
}
@media (max-width: 360px) {
.hs__view {
height: 160px;
}
.hs__note {
width: 112px;
left: -56px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs.is-demo .hs__note,
.hs.is-demo .hs__pin {
animation: none !important;
}
.hs.is-demo .hs__spot--2 .hs__note {
opacity: 1;
transform: scale(1);
}
.hs.is-demo .hs__spot--2 .hs__pin {
background: #ffd23f;
color: #17141a;
}
}// 실제 동작: 핀은 버튼이다. 열림 여부는 색이 아니라 aria-expanded 에 적힌다.
var hs = document.getElementById('hs');
var hsPins = [].slice.call(hs.querySelectorAll('.hs__pin'));
function hsOpen(pin) {
hs.classList.remove('is-demo'); // 사람이 누르면 자동 루프를 멈춘다
var was = pin.getAttribute('aria-expanded') === 'true';
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
pin.setAttribute('aria-expanded', was ? 'false' : 'true');
}
hsPins.forEach(function (pin) {
pin.addEventListener('click', function () { hsOpen(pin); });
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
hs.classList.remove('is-demo');
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HotspotPins.module.scss";
export type Hotspot = { pinLabel: string; note: string };
export type HotspotPinsProps = {
viewLabel?: string;
spots?: Hotspot[];
/** 자동 재생 루프 (갤러리용). 사람이 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const Info = () => (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M144 176a8 8 0 0 1-8 8a16 16 0 0 1-16-16v-40a8 8 0 0 1 0-16a16 16 0 0 1 16 16v40a8 8 0 0 1 8 8m88-48A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88m-92-32a12 12 0 1 0-12-12a12 12 0 0 0 12 12" />
</g>
</svg>
);
const DEFAULT_SPOTS: Hotspot[] = [
{ pinLabel: "About the top", note: "Oak top" },
{ pinLabel: "About the handle", note: "Brass handle" },
{ pinLabel: "About the legs", note: "Matte black legs" },
];
export default function HotspotPins({
viewLabel = "Desk photo",
spots = DEFAULT_SPOTS,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffd23f",
}: HotspotPinsProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(-1);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const place = [styles.spot1, styles.spot2, styles.spot3];
return (
<div
className={[styles.root, live ? styles.isDemo : ""].join(" ")}
style={vars}
onKeyDown={(e: KeyEvt) => {
if (e.key !== "Escape") return;
setLive(false);
setOpen(-1);
}}
>
<div className={styles.view} role="img" aria-label={viewLabel}>
{spots.map((s, i) => (
<div className={[styles.spot, place[i]].join(" ")} key={i}>
<button
className={styles.pin}
type="button"
aria-expanded={open === i}
aria-controls={`hs-n${i + 1}`}
aria-label={s.pinLabel}
onClick={() => { setLive(false); setOpen(open === i ? -1 : i); }}
>
<Info />
</button>
<span className={styles.note} id={`hs-n${i + 1}`}>{s.note}</span>
</div>
))}
</div>
</div>
);
}09 · 사진 위에 꽂힌 설명 핀 — 측정 누적면적 7.31% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
그러면 낭독기에 전해지는 값과 눈에 보이는 모습이 갈라질 자리가 없습니다.
function hsOpen(pin) {
var was = pin.getAttribute('aria-expanded') === 'true';
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
pin.setAttribute('aria-expanded', was ? 'false' : 'true');
}
hsPins.forEach(function (pin) {
pin.addEventListener('click', function () { hsOpen(pin); });
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
});
어디서 깨지는가 — 함정
뷰어에서 가장 자주 깨지는 곳은 자동 재생용 연출과 사람이 실제로 만졌을 때의 경로가 따로 노는 것입니다. 격자에 걸린 아홉 칸은 전부 자동 루프로 돌아가는데, 그 루프를 CSS 키프레임에만 맡겨 두면 마우스를 올려 루프가 꺼지는 순간 화면이 비어 버립니다. 01의 렌즈가 바로 그 자리였습니다. 키프레임이 transform 과 background-position 을 둘 다 쥐고 있었으므로, 루프를 끈 정지 상태에도 같은 두 값을 기본 규칙으로 먼저 적어 둔 다음 그 위에 루프를 덧씌웠습니다. 아홉 개 전부 루프를 뗀 화면을 따로 한 번씩 찍어 확인했습니다.
두 번째는 좌표 계산의 기준입니다. 마우스 좌표는 화면 왼쪽 위가 0이지만 그림 안의 좌표는 그림 왼쪽 위가 0이라, getBoundingClientRect 로 잰 상자의 left 와 top 을 빼 주지 않으면 렌즈와 스포트라이트가 손끝에서 몇십 픽셀씩 떨어져 따라옵니다. 06의 라이트박스도 같은 계산을 두 번 합니다. 썸네일의 상자와 큰 판의 상자를 각각 재서 그 차이를 되돌림 값으로 쓰고, 중심끼리 맞추려고 두 상자의 폭과 높이 차이를 2로 나눠 더합니다.
마지막 함정은 화면이 손바닥만 해질 때 나옵니다. 이 데모들은 글 안에 480×300 iframe 으로 들어가고 휴대폰에서는 320×200으로 줄어듭니다. 무대 여백을 뺀 자리는 가로 294px, 세로 174px뿐인데 320px에서 재어 보니 가로는 05를 뺀 여덟 개가 288px로 같았고 세로는 144px에서 168px 사이였습니다. 가장 큰 01과 02가 168px이니 남는 여유는 6px입니다. 그래서 좁은 화면 규칙에서는 간격을 올리지 않고 내리기만 했습니다. 돋보기를 손가락으로 움직이게 바꾸는 일은 모바일 터치 제스처의 같은 좌표 추적으로 해결됩니다. 아홉 벌을 한 폴더에 모아 두었고 그 압축 파일의 비밀번호는 ce7bbmt2 이며, 안에는 지금 보고 있는 바닐라 판과 같은 동작의 React 판이 함께 들어 있습니다.
접근성
아홉 개 전부 마우스가 없어도 쓸 수 있어야 뷰어입니다. 확대·이동은 화살표 키, 배율은 버튼, 열고 닫기는 Enter와 Escape에 걸어 두었고, prefers-reduced-motion: reduce 에서는 자동 재생만 멈추고 보여 주던 상태는 그대로 남깁니다. 움직임을 끈다고 해서 "지금 2배로 보고 있다"는 사실까지 사라지면 안 되기 때문입니다.
| 항목 | 마우스 | 키보드 | 낭독기 |
|---|---|---|---|
| 01 확대 렌즈 | 포인터 이동 | 화살표 키 16px씩 | role="img" 와 조작 안내 |
| 02 휠 줌·패닝 | 휠 · 드래그 | 화살표 키와 + - |
role="img" 와 조작 안내 |
| 03 미니맵 | 드래그 | 화살표 키 | role="img" 와 조작 안내 |
| 04 배율 컨트롤 | 버튼 세 개 | 탭 · Enter | aria-live 로 바뀐 배율 |
| 05 회전·반전 | 버튼 두 개 | 탭 · Enter | aria-pressed 로 뒤집힘 |
| 06 라이트박스 | 썸네일 클릭 | Escape · 좌우 화살표 | role="dialog" 와 닫기 버튼 |
| 07 360도 뷰 | 드래그 | 좌우 화살표 | role="img" 와 조작 안내 |
| 08 스포트라이트 | 포인터 이동 | 화살표 키 20px씩 | role="img" 와 조작 안내 |
| 09 설명 핀 | 핀 클릭 | 탭 · Enter · Escape | aria-expanded 로 열림 |
포커스를 옮기는 것은 아홉 개 중 06뿐입니다. 라이트박스가 열리면 닫기 버튼으로, 닫히면 눌렀던 썸네일로 돌아오는데 둘 다 focus({ preventScroll: true }) 로 넘깁니다. 데모가 iframe 안에 들어 있어 이 옵션이 없으면 바깥 페이지가 그 자리로 끌려 내려갑니다. mask-image 의 브라우저 지원과 접두사가 필요한 범위는 MDN의 mask-image 문서에 정리되어 있습니다. 같은 갈래의 화면은 미디어 카테고리에, 손을 올렸을 때 반응하는 것들은 호버 카테고리에 모여 있습니다.
FAQ
이미지 확대 보기에 라이브러리를 쓰는 게 낫지 않나요?
아홉 개 모두 라이브러리 없이 예순 줄 안쪽으로 끝났습니다. 뷰어 라이브러리는 대개 핀치 줌, 관성 스크롤, 여러 장 넘김을 한꺼번에 들고 오는데, 상품 사진 한 장에 렌즈 하나만 필요한 화면에서는 그 전부가 짐이 됩니다. 반대로 파일 형식 변환이나 회전한 원본 저장처럼 그림 자체를 바꿔야 하는 일이 끼면 그때는 전용 도구가 맞습니다. 확대한 화면 위에 색을 고르는 도구를 얹으려면 컬러 피커 직접 만들기가 짝입니다.
이미지 크게 보기에서 그림이 흐려지는 건 어떻게 막나요?
여기 아홉 개는 그라디언트로 그린 그림이라 몇 배로 키워도 계산으로 다시 그려져 또렷합니다. 하지만 진짜 사진을 쓸 때는 화면에 보이는 크기보다 큰 원본을 따로 두고, 확대할 때만 그 원본으로 갈아 끼우는 방법밖에 없습니다. 01처럼 렌즈만 쓰는 화면이라면 렌즈 안 배경에만 큰 원본을 주고 바탕은 작은 파일로 두면 됩니다.
터치 화면에서도 같은 코드가 도나요?
pointermove 와 pointerdown 은 마우스·펜·손가락을 한 이름으로 받으므로 02·03·07은 그대로 끌립니다. 다만 손가락으로 끌 때 페이지가 함께 스크롤되지 않게 touch-action: none 을 상자에 걸어 두어야 하고, 두 손가락 핀치는 pointermove 두 개를 직접 세어야 해서 이 아홉 개에는 넣지 않았습니다. 대신 02는 + 와 - 키로, 04는 버튼으로 같은 배율을 만들 수 있게 해 두었습니다.









