05An Image That Scales From Eight Handles With Ratio Locked
The opposite corner is the anchor, height is derived from the width change by ratio, and corner handles read size from the diagonal projection.
Best forImage editors
ASPECTMIN_SIDEresizeTomaxS

<main class="stage">
<div class="rh is-demo" id="rh" data-demo="1">
<div class="rh__img" data-r="img"><i class="rh__sun"></i><i class="rh__hill"></i></div>
<i class="rh__proj" data-r="proj"></i>
<button class="rh__h" data-hx="-1" data-hy="-1" type="button" aria-label="Resize handle"></button>
<button class="rh__h" data-hx="0" data-hy="-1" type="button" aria-label="Resize handle"></button>
<button class="rh__h" data-hx="1" data-hy="-1" type="button" aria-label="Resize handle"></button>
<button class="rh__h" data-hx="-1" data-hy="0" type="button" aria-label="Resize handle"></button>
<button class="rh__h" data-hx="1" data-hy="0" type="button" aria-label="Resize handle"></button>
<button class="rh__h" data-hx="-1" data-hy="1" type="button" aria-label="Resize handle"></button>
<button class="rh__h" data-hx="0" data-hy="1" type="button" aria-label="Resize handle"></button>
<button class="rh__h" data-hx="1" data-hy="1" type="button" aria-label="Resize handle"></button>
<p class="rh__meta"><span class="rh__tag">4:3</span> <span class="rh__tag">W</span> <b data-r="w">0</b> <span class="rh__tag">H</span> <b data-r="h">0</b></p>
<i class="rh__cursor" data-r="cursor"></i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 앵커(반대쪽 꼭짓점·변)는 움직이지 않고, 모서리는 포인터 벡터를 비율 대각선 위로 투영해 가로세로를 한 값으로 정한다.
@include stage($stage-paper);
.rh {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.rh b { font-weight: 800; font-variant-numeric: tabular-nums; }
.rh__tag { color: rgba($subject-ink, .72); letter-spacing: .5px; }
.rh__cursor {
position: absolute;
left: -10px;
top: -10px;
width: 20px;
height: 20px;
border-radius: 50%;
background: $subject-cream;
box-shadow: 0 0 0 3px rgba($subject-ink, .55), $shadow-press;
opacity: 0;
pointer-events: none;
z-index: 6;
}
.rh[data-demo] .rh__cursor { opacity: 1; }
.rh button:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.rh__cursor { display: none; }
}
// 점선은 포인터가 실제로 있는 곳, 손잡이는 투영된 꼭짓점이다.
.rh__img {
position: absolute;
left: 0;
top: 0;
overflow: hidden;
border-radius: $r-card;
background: linear-gradient(160deg, $subject-sky, $subject-lilac);
box-shadow: 0 8px 16px rgba($subject-ink, .24);
}
.rh__sun {
position: absolute;
right: 14%;
top: 14%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: $subject-pink;
}
.rh__hill {
position: absolute;
left: -10%;
bottom: -72%;
width: 120%;
height: 100%;
border-radius: 50%;
background: $subject-mint;
}
.rh__h {
position: absolute;
left: 0;
top: 0;
width: 28px;
height: 28px;
padding: 0;
border: 0;
background: transparent;
z-index: 3;
touch-action: none;
}
.rh__h::before {
content: "";
position: absolute;
left: 8px;
top: 8px;
width: 12px;
height: 12px;
box-sizing: border-box;
border: 2px solid $subject-ink;
border-radius: $r-xs;
background: $subject-cream;
}
.rh__h[data-hx="-1"][data-hy="-1"],
.rh__h[data-hx="1"][data-hy="1"] { cursor: nwse-resize; }
.rh__h[data-hx="1"][data-hy="-1"],
.rh__h[data-hx="-1"][data-hy="1"] { cursor: nesw-resize; }
.rh__h[data-hx="0"] { cursor: ns-resize; }
.rh__h[data-hy="0"] { cursor: ew-resize; }
.rh__h:focus-visible::before { box-shadow: 0 0 0 3px $subject-pink; }
.rh__proj {
position: absolute;
left: 0;
top: 0;
height: 0;
border-top: 2px dashed $subject-pink;
transform-origin: 0 0;
opacity: 0;
pointer-events: none;
z-index: 4;
}
.rh__meta {
position: absolute;
left: 0;
bottom: 0;
margin: 0;
padding: 0 $sp-2;
border-radius: $r-xs;
background: rgba($subject-ink, .72);
z-index: 5;
}
.rh__meta { color: $subject-cream; }
.rh__meta .rh__tag { color: rgba($subject-cream, .78); }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);
}
.rh {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.rh b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.rh__tag {
color: rgba(23, 20, 26, 0.72);
letter-spacing: 0.5px;
}
.rh__cursor {
position: absolute;
left: -10px;
top: -10px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 0 0 3px rgba(23, 20, 26, 0.55), 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.rh[data-demo] .rh__cursor {
opacity: 1;
}
.rh button:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.rh__cursor {
display: none;
}
}
.rh__img {
position: absolute;
left: 0;
top: 0;
overflow: hidden;
border-radius: 16px;
background: linear-gradient(160deg, #6ec8ff, #b9a0ff);
box-shadow: 0 8px 16px rgba(23, 20, 26, 0.24);
}
.rh__sun {
position: absolute;
right: 14%;
top: 14%;
width: 20%;
aspect-ratio: 1;
border-radius: 50%;
background: #ff9ebb;
}
.rh__hill {
position: absolute;
left: -10%;
bottom: -72%;
width: 120%;
height: 100%;
border-radius: 50%;
background: #4cd4a6;
}
.rh__h {
position: absolute;
left: 0;
top: 0;
width: 28px;
height: 28px;
padding: 0;
border: 0;
background: transparent;
z-index: 3;
touch-action: none;
}
.rh__h::before {
content: "";
position: absolute;
left: 8px;
top: 8px;
width: 12px;
height: 12px;
box-sizing: border-box;
border: 2px solid #17141a;
border-radius: 4px;
background: #fff7e6;
}
.rh__h[data-hx="-1"][data-hy="-1"],
.rh__h[data-hx="1"][data-hy="1"] {
cursor: nwse-resize;
}
.rh__h[data-hx="1"][data-hy="-1"],
.rh__h[data-hx="-1"][data-hy="1"] {
cursor: nesw-resize;
}
.rh__h[data-hx="0"] {
cursor: ns-resize;
}
.rh__h[data-hy="0"] {
cursor: ew-resize;
}
.rh__h:focus-visible::before {
box-shadow: 0 0 0 3px #ff9ebb;
}
.rh__proj {
position: absolute;
left: 0;
top: 0;
height: 0;
border-top: 2px dashed #ff9ebb;
transform-origin: 0 0;
opacity: 0;
pointer-events: none;
z-index: 4;
}
.rh__meta {
position: absolute;
left: 0;
bottom: 0;
margin: 0;
padding: 0 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.72);
z-index: 5;
}
.rh__meta {
color: #fff7e6;
}
.rh__meta .rh__tag {
color: rgba(255, 247, 230, 0.78);
}(function () {
const root = document.getElementById("rh");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
const offs = [];
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function stopDemo() {
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
}
function pos(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function spring(x, v, to, k, c, dt) {
const n = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
v += (-k * (x - to) - c * v) * h;
x += v * h;
}
return [x, v];
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 잡은 손잡이의 반대쪽(앵커)을 고정하고, 모서리는 앵커에서 포인터까지의 벡터를
// 비율 대각선 (ASPECT, 1) 위로 투영해 한 변 길이 s 하나로 가로·세로를 같이 정한다.
const ASPECT = 4 / 3;
const MIN_SIDE = 48;
const img = q("[data-r=img]");
const proj = q("[data-r=proj]");
const cursor = q("[data-r=cursor]");
const wEl = q("[data-r=w]");
const hEl = q("[data-r=h]");
const handles = Array.prototype.slice.call(root.querySelectorAll("[data-hx]"));
let x = 0;
let y = 0;
let w = 0;
let h = 0;
let px = 0;
let py = 0;
let active = -1;
let gx = 0;
let gy = 0;
let ready = false;
function reset() {
const s = root.clientHeight * 0.4;
w = s * ASPECT;
h = s;
x = root.clientWidth * 0.1;
y = root.clientHeight * 0.14;
}
function init() {
if (ready)
return;
ready = true;
reset();
}
function hx(i) { return Number(handles[i].getAttribute("data-hx")); }
function hy(i) { return Number(handles[i].getAttribute("data-hy")); }
function resizeTo(i, qx, qy) {
const W = root.clientWidth;
const H = root.clientHeight;
const sx = hx(i);
const sy = hy(i);
const ax = sx > 0 ? x : sx < 0 ? x + w : x + w / 2;
const ay = sy > 0 ? y : sy < 0 ? y + h : y + h / 2;
if (sx !== 0 && sy !== 0) {
const dx = (qx - ax) * sx;
const dy = (qy - ay) * sy;
const maxS = Math.min((sx > 0 ? W - ax : ax) / ASPECT, sy > 0 ? H - ay : ay);
const s = clamp((dx * ASPECT + dy) / (ASPECT * ASPECT + 1), MIN_SIDE, maxS);
w = s * ASPECT;
h = s;
x = sx > 0 ? ax : ax - w;
y = sy > 0 ? ay : ay - h;
}
else if (sx !== 0) {
const maxW = Math.min(sx > 0 ? W - ax : ax, 2 * Math.min(ay, H - ay) * ASPECT);
w = clamp((qx - ax) * sx, MIN_SIDE * ASPECT, maxW);
h = w / ASPECT;
x = sx > 0 ? ax : ax - w;
y = ay - h / 2;
}
else {
const maxH = Math.min(sy > 0 ? H - ay : ay, (2 * Math.min(ax, W - ax)) / ASPECT);
h = clamp((qy - ay) * sy, MIN_SIDE, maxH);
w = h * ASPECT;
x = ax - w / 2;
y = sy > 0 ? ay : ay - h;
}
}
function userTakes() {
stopDemo();
active = -1;
init();
}
function handlePoint(i) {
return [x + ((hx(i) + 1) / 2) * w, y + ((hy(i) + 1) / 2) * h];
}
function leg(l, a, b, from, to) {
const t = seg(l, a, b);
return [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
}
function script(now) {
const W = root.clientWidth;
const H = root.clientHeight;
const ph = now % 2000;
const corner = ph < 1000;
const l = ph % 1000;
const i = corner ? 7 : 4;
const s0 = H * 0.4;
const p0 = [W * 0.1 + s0 * ASPECT, corner ? H * 0.14 + s0 : H * 0.14 + s0 / 2];
const p1 = corner ? [W * 0.88, H * 0.62] : [W * 0.95, p0[1]];
const p2 = corner ? [W * 0.46, H * 0.95] : [W * 0.5, p0[1]];
let q = p0;
if (l < 200) {
reset();
active = -1;
px = p0[0];
py = p0[1];
return;
}
if (l < 520)
q = leg(l, 200, 520, p0, p1);
else if (l < 640)
q = p1;
else if (l < 860)
q = leg(l, 640, 860, p1, p2);
else
q = leg(l, 860, 1000, p2, p0);
active = i;
resizeTo(i, q[0], q[1]);
px = q[0];
py = q[1];
}
function snap() { init(); }
function step(_dt, now) {
init();
if (demo)
script(now);
}
function render() {
img.style.transform = "translate(" + x.toFixed(1) + "px," + y.toFixed(1) + "px)";
img.style.width = w.toFixed(1) + "px";
img.style.height = h.toFixed(1) + "px";
for (let i = 0; i < handles.length; i += 1) {
const p = handlePoint(i);
handles[i].style.transform = "translate(" + (p[0] - 14).toFixed(1) + "px," + (p[1] - 14).toFixed(1) + "px)";
}
if (active >= 0 && hx(active) !== 0 && hy(active) !== 0) {
const c = handlePoint(active);
const dx = px - c[0];
const dy = py - c[1];
proj.style.opacity = "1";
proj.style.width = Math.hypot(dx, dy).toFixed(1) + "px";
proj.style.transform = "translate(" + c[0].toFixed(1) + "px," + c[1].toFixed(1) + "px) rotate(" + Math.atan2(dy, dx).toFixed(3) + "rad)";
}
else {
proj.style.opacity = "0";
}
wEl.textContent = String(Math.round(w));
hEl.textContent = String(Math.round(h));
cursor.style.transform = "translate(" + px.toFixed(1) + "px," + py.toFixed(1) + "px)";
}
handles.forEach((el, i) => {
on(el, "pointerdown", (e) => {
userTakes();
el.setPointerCapture(e.pointerId);
const p = pos(e);
const c = handlePoint(i);
gx = c[0] - p.x;
gy = c[1] - p.y;
px = p.x;
py = p.y;
active = i;
});
on(el, "pointermove", (e) => {
if (active !== i)
return;
const p = pos(e);
px = p.x;
py = p.y;
resizeTo(i, px + gx, py + gy);
sync();
});
on(el, "pointerup", () => { active = -1; sync(); });
on(el, "pointercancel", () => { active = -1; sync(); });
on(el, "keydown", (e) => {
const k = e.key;
if (k !== "ArrowLeft" && k !== "ArrowRight" && k !== "ArrowUp" && k !== "ArrowDown")
return;
e.preventDefault();
userTakes();
const c = handlePoint(i);
px = c[0] + (k === "ArrowRight" ? 8 : k === "ArrowLeft" ? -8 : 0);
py = c[1] + (k === "ArrowDown" ? 8 : k === "ArrowUp" ? -8 : 0);
resizeTo(i, px, py);
sync();
});
});
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
const dt = Math.min(0.05, (ts - last) / 1000);
last = ts;
step(dt, ts - t0);
render();
raf = requestAnimationFrame(frame);
}
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// RatioLockHandles - vanilla 판(05-rh-ratio-lock-eight-handles) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RatioLockHandles.module.scss";
export default function RatioLockHandles() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
const offs: Array<() => void> = [];
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function stopDemo(): void {
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
}
function pos(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function spring(x: number, v: number, to: number, k: number, c: number, dt: number): number[] {
const n = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
v += (-k * (x - to) - c * v) * h;
x += v * h;
}
return [x, v];
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 잡은 손잡이의 반대쪽(앵커)을 고정하고, 모서리는 앵커에서 포인터까지의 벡터를
// 비율 대각선 (ASPECT, 1) 위로 투영해 한 변 길이 s 하나로 가로·세로를 같이 정한다.
const ASPECT = 4 / 3;
const MIN_SIDE = 48;
const img = q<HTMLElement>("[data-r=img]");
const proj = q<HTMLElement>("[data-r=proj]");
const cursor = q<HTMLElement>("[data-r=cursor]");
const wEl = q<HTMLElement>("[data-r=w]");
const hEl = q<HTMLElement>("[data-r=h]");
const handles = Array.prototype.slice.call(root.querySelectorAll("[data-hx]")) as HTMLElement[];
let x = 0;
let y = 0;
let w = 0;
let h = 0;
let px = 0;
let py = 0;
let active = -1;
let gx = 0;
let gy = 0;
let ready = false;
function reset(): void {
const s = root.clientHeight * 0.4;
w = s * ASPECT;
h = s;
x = root.clientWidth * 0.1;
y = root.clientHeight * 0.14;
}
function init(): void {
if (ready) return;
ready = true;
reset();
}
function hx(i: number): number { return Number(handles[i].getAttribute("data-hx")); }
function hy(i: number): number { return Number(handles[i].getAttribute("data-hy")); }
function resizeTo(i: number, qx: number, qy: number): void {
const W = root.clientWidth;
const H = root.clientHeight;
const sx = hx(i);
const sy = hy(i);
const ax = sx > 0 ? x : sx < 0 ? x + w : x + w / 2;
const ay = sy > 0 ? y : sy < 0 ? y + h : y + h / 2;
if (sx !== 0 && sy !== 0) {
const dx = (qx - ax) * sx;
const dy = (qy - ay) * sy;
const maxS = Math.min((sx > 0 ? W - ax : ax) / ASPECT, sy > 0 ? H - ay : ay);
const s = clamp((dx * ASPECT + dy) / (ASPECT * ASPECT + 1), MIN_SIDE, maxS);
w = s * ASPECT;
h = s;
x = sx > 0 ? ax : ax - w;
y = sy > 0 ? ay : ay - h;
} else if (sx !== 0) {
const maxW = Math.min(sx > 0 ? W - ax : ax, 2 * Math.min(ay, H - ay) * ASPECT);
w = clamp((qx - ax) * sx, MIN_SIDE * ASPECT, maxW);
h = w / ASPECT;
x = sx > 0 ? ax : ax - w;
y = ay - h / 2;
} else {
const maxH = Math.min(sy > 0 ? H - ay : ay, (2 * Math.min(ax, W - ax)) / ASPECT);
h = clamp((qy - ay) * sy, MIN_SIDE, maxH);
w = h * ASPECT;
x = ax - w / 2;
y = sy > 0 ? ay : ay - h;
}
}
function userTakes(): void {
stopDemo();
active = -1;
init();
}
function handlePoint(i: number): number[] {
return [x + ((hx(i) + 1) / 2) * w, y + ((hy(i) + 1) / 2) * h];
}
function leg(l: number, a: number, b: number, from: number[], to: number[]): number[] {
const t = seg(l, a, b);
return [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
}
function script(now: number): void {
const W = root.clientWidth;
const H = root.clientHeight;
const ph = now % 2000;
const corner = ph < 1000;
const l = ph % 1000;
const i = corner ? 7 : 4;
const s0 = H * 0.4;
const p0 = [W * 0.1 + s0 * ASPECT, corner ? H * 0.14 + s0 : H * 0.14 + s0 / 2];
const p1 = corner ? [W * 0.88, H * 0.62] : [W * 0.95, p0[1]];
const p2 = corner ? [W * 0.46, H * 0.95] : [W * 0.5, p0[1]];
let q = p0;
if (l < 200) { reset(); active = -1; px = p0[0]; py = p0[1]; return; }
if (l < 520) q = leg(l, 200, 520, p0, p1);
else if (l < 640) q = p1;
else if (l < 860) q = leg(l, 640, 860, p1, p2);
else q = leg(l, 860, 1000, p2, p0);
active = i;
resizeTo(i, q[0], q[1]);
px = q[0];
py = q[1];
}
function snap(): void { init(); }
function step(_dt: number, now: number): void {
init();
if (demo) script(now);
}
function render(): void {
img.style.transform = "translate(" + x.toFixed(1) + "px," + y.toFixed(1) + "px)";
img.style.width = w.toFixed(1) + "px";
img.style.height = h.toFixed(1) + "px";
for (let i = 0; i < handles.length; i += 1) {
const p = handlePoint(i);
handles[i].style.transform = "translate(" + (p[0] - 14).toFixed(1) + "px," + (p[1] - 14).toFixed(1) + "px)";
}
if (active >= 0 && hx(active) !== 0 && hy(active) !== 0) {
const c = handlePoint(active);
const dx = px - c[0];
const dy = py - c[1];
proj.style.opacity = "1";
proj.style.width = Math.hypot(dx, dy).toFixed(1) + "px";
proj.style.transform = "translate(" + c[0].toFixed(1) + "px," + c[1].toFixed(1) + "px) rotate(" + Math.atan2(dy, dx).toFixed(3) + "rad)";
} else {
proj.style.opacity = "0";
}
wEl.textContent = String(Math.round(w));
hEl.textContent = String(Math.round(h));
cursor.style.transform = "translate(" + px.toFixed(1) + "px," + py.toFixed(1) + "px)";
}
handles.forEach((el, i) => {
on(el, "pointerdown", (e: PointerEvent) => {
userTakes();
el.setPointerCapture(e.pointerId);
const p = pos(e);
const c = handlePoint(i);
gx = c[0] - p.x;
gy = c[1] - p.y;
px = p.x;
py = p.y;
active = i;
});
on(el, "pointermove", (e: PointerEvent) => {
if (active !== i) return;
const p = pos(e);
px = p.x;
py = p.y;
resizeTo(i, px + gx, py + gy);
sync();
});
on(el, "pointerup", () => { active = -1; sync(); });
on(el, "pointercancel", () => { active = -1; sync(); });
on(el, "keydown", (e: KeyboardEvent) => {
const k = e.key;
if (k !== "ArrowLeft" && k !== "ArrowRight" && k !== "ArrowUp" && k !== "ArrowDown") return;
e.preventDefault();
userTakes();
const c = handlePoint(i);
px = c[0] + (k === "ArrowRight" ? 8 : k === "ArrowLeft" ? -8 : 0);
py = c[1] + (k === "ArrowDown" ? 8 : k === "ArrowUp" ? -8 : 0);
resizeTo(i, px, py);
sync();
});
});
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
const dt = Math.min(0.05, (ts - last) / 1000);
last = ts;
step(dt, ts - t0);
render();
raf = requestAnimationFrame(frame);
}
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["rh"] + " is-demo"} data-demo="1">
<div className={styles["rh__img"]} data-r="img"><i className={styles["rh__sun"]}></i><i className={styles["rh__hill"]}></i></div>
<i className={styles["rh__proj"]} data-r="proj"></i>
<button className={styles["rh__h"]} data-hx="-1" data-hy="-1" type="button" aria-label="크기 조절 손잡이"></button>
<button className={styles["rh__h"]} data-hx="0" data-hy="-1" type="button" aria-label="크기 조절 손잡이"></button>
<button className={styles["rh__h"]} data-hx="1" data-hy="-1" type="button" aria-label="크기 조절 손잡이"></button>
<button className={styles["rh__h"]} data-hx="-1" data-hy="0" type="button" aria-label="크기 조절 손잡이"></button>
<button className={styles["rh__h"]} data-hx="1" data-hy="0" type="button" aria-label="크기 조절 손잡이"></button>
<button className={styles["rh__h"]} data-hx="-1" data-hy="1" type="button" aria-label="크기 조절 손잡이"></button>
<button className={styles["rh__h"]} data-hx="0" data-hy="1" type="button" aria-label="크기 조절 손잡이"></button>
<button className={styles["rh__h"]} data-hx="1" data-hy="1" type="button" aria-label="크기 조절 손잡이"></button>
<p className={styles["rh__meta"]}><span className={styles["rh__tag"]}>4:3</span> <span className={styles["rh__tag"]}>W</span> <b data-r="w">0</b> <span className={styles["rh__tag"]}>H</span> <b data-r="h">0</b></p>
<i className={styles["rh__cursor"]} data-r="cursor"></i>
</div>
</main>
);
}05 An Image That Scales From Eight Handles With Ratio Locked — Measured area 50.427% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · CSS Resize Handle: 9 Demos That Follow Your Grip
The post this demo lives in
CSS Resize Handle: 9 Demos That Follow Your GripA css resize handle left to the resize property alone cannot change its shape, ignores line height and just stops at the See all demos in this post
If you like this effect - similar demos
drag05 Long-press gate — splitting scroll from drag with a hold timer and a movement thresholdDrag to Reorder UI: 9 Lists, One Drop Index
drag01 Image drop — an instant preview drawn from an object URL the moment it landsDrag and Drop File Upload UI: 9 Computed Dropzones
drag06 An Auto Solver That Starts from the BorderJigsaw Puzzle JavaScript: 9 Snapping Tab Boards