09Image warp tracking — velocity stretch
Measure the cursor's per-frame travel and stretch the picture that way with skewX and scale, so the image grabs and elongates with cursor speed.
Best forGallery hero, artwork cards
skewXscalerequestAnimationFrame

<main class="stage">
<div class="wp is-demo" id="wp">
<div class="wp__field" role="img" aria-label="A dawn scene stretches with cursor speed and springs back">
<div class="wp__frame">
<div class="wp__art" id="wpArt">
<span class="wp__sun" aria-hidden="true"></span>
<span class="wp__ridge wp__ridge--far" aria-hidden="true"></span>
<span class="wp__ridge wp__ridge--near" aria-hidden="true"></span>
</div>
</div>
</div>
<p class="wp__hint">Move the mouse fast and the picture stretches, then returns</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 이미지 왜곡 추적 — 커서의 매 프레임 이동량을 재 skewX 로 방향별로 비틀고 scale 로 늘렸다가,
// 속도가 죽으면 lerp 로 0으로 되돌아간다. 왜곡은 그림 안쪽에만 두고 테두리는 그대로 둔다.
// 격자 미리보기(.is-demo)는 그림이 좌우로 늘어났다 되돌아오는 숨을 @keyframes 로 돌린다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.wp { display: grid; justify-items: center; gap: $sp-3; width: 100%; }
.wp__field {
display: grid; place-items: center;
width: 100%; height: clamp(110px, 32vh, 160px);
}
.wp__frame {
box-sizing: border-box;
width: min(100%, 300px); height: clamp(96px, 28vh, 140px);
padding: $sp-2;
background: $subject-cream;
border: 3px solid $color; border-radius: $r-card;
}
// 그림 — 실제로 왜곡되는 주인공. 새벽 풍경(그라디언트 + 해 + 능선)
.wp__art {
position: relative; overflow: hidden;
width: 100%; height: 100%;
border-radius: $r-xs;
background: linear-gradient(180deg, $subject-lilac 0%, $subject-pink 74%, $stage-orange 130%);
will-change: transform;
}
.wp__sun {
position: absolute; right: 16%; top: 22%;
width: 26px; height: 26px;
background: $stage-yellow; border-radius: 50%;
}
.wp__ridge { position: absolute; left: -6%; right: -6%; border-radius: $r-pill $r-pill 0 0; }
.wp__ridge--far {
bottom: 30%; height: 30%;
background: rgba($subject-ink, .38);
}
.wp__ridge--near {
bottom: -4%; height: 42%;
background: rgba($subject-ink, .82);
}
.wp__hint { margin: 0; color: $color; font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); }
// ── 미리보기 루프: 왼쪽으로 스치면 왼쪽으로 비틀려 늘고, 돌아와서 쉬었다가 오른쪽으로 간다
.wp.is-demo .wp__art { animation: wpStretch $duration $easing infinite; }
@keyframes wpStretch {
0%, 8% { transform: skewX(0deg) scale(1); }
22% { transform: skewX(-9deg) scale(1.1); }
36%, 48% { transform: skewX(0deg) scale(1); }
62% { transform: skewX(9deg) scale(1.1); }
76%, 100% { transform: skewX(0deg) scale(1); }
}
@media (max-width: 340px) {
.wp { gap: $sp-2; }
.wp__field { height: clamp(96px, 28vh, 130px); }
.wp__frame { height: clamp(84px, 24vh, 112px); }
}
@media (prefers-reduced-motion: reduce) {
.wp.is-demo .wp__art { animation: none !important; }
// reduce 에서는 왜곡 없이 원본 비율로 세운다
.wp.is-demo .wp__art { transform: skewX(0deg) scale(1) !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);
}
.wp {
display: grid;
justify-items: center;
gap: 12px;
width: 100%;
}
.wp__field {
display: grid;
place-items: center;
width: 100%;
height: clamp(110px, 32vh, 160px);
}
.wp__frame {
box-sizing: border-box;
width: min(100%, 300px);
height: clamp(96px, 28vh, 140px);
padding: 8px;
background: #fff7e6;
border: 3px solid #fff7e6;
border-radius: 16px;
}
.wp__art {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
border-radius: 4px;
background: linear-gradient(180deg, #b9a0ff 0%, #ff9ebb 74%, #ff4d1f 130%);
will-change: transform;
}
.wp__sun {
position: absolute;
right: 16%;
top: 22%;
width: 26px;
height: 26px;
background: #ffd23f;
border-radius: 50%;
}
.wp__ridge {
position: absolute;
left: -6%;
right: -6%;
border-radius: 999px 999px 0 0;
}
.wp__ridge--far {
bottom: 30%;
height: 30%;
background: rgba(23, 20, 26, 0.38);
}
.wp__ridge--near {
bottom: -4%;
height: 42%;
background: rgba(23, 20, 26, 0.82);
}
.wp__hint {
margin: 0;
color: #fff7e6;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
}
.wp.is-demo .wp__art {
animation: wpStretch 2s ease-out infinite;
}
@keyframes wpStretch {
0%, 8% {
transform: skewX(0deg) scale(1);
}
22% {
transform: skewX(-9deg) scale(1.1);
}
36%, 48% {
transform: skewX(0deg) scale(1);
}
62% {
transform: skewX(9deg) scale(1.1);
}
76%, 100% {
transform: skewX(0deg) scale(1);
}
}
@media (max-width: 340px) {
.wp {
gap: 8px;
}
.wp__field {
height: clamp(96px, 28vh, 130px);
}
.wp__frame {
height: clamp(84px, 24vh, 112px);
}
}
@media (prefers-reduced-motion: reduce) {
.wp.is-demo .wp__art {
animation: none !important;
}
.wp.is-demo .wp__art {
transform: skewX(0deg) scale(1) !important;
}
}var root = document.getElementById('wp');
var art = document.getElementById('wpArt');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function lerp(a, b, k) { return a + (b - a) * k; }
// 커서의 매 프레임 이동량(속도)을 부드럽게 쌓는다 — 멈추면 0으로 꺼진다
var vx = 0, vy = 0;
var sx = 0, sy = 0;
var last = null;
function setFromPointer(cx, cy) {
if (last) {
vx = cx - last.x;
vy = cy - last.y;
}
last = { x: cx, y: cy };
}
function tick() {
// 속도를 매 프레임 12%씩만 남긴다 — 커서가 멈추면 왜곡이 스르르 풀린다
sx = lerp(sx, vx, 0.24);
sy = lerp(sy, vy, 0.24);
vx = lerp(vx, 0, 0.12);
vy = lerp(vy, 0, 0.12);
var skew = Math.max(-11, Math.min(11, sx * 0.32));
var stretch = 1 + Math.min(0.14, Math.abs(sx) * 0.011 + Math.abs(sy) * 0.009);
art.style.transform = 'skewX(' + skew.toFixed(2) + 'deg) scale(' + stretch.toFixed(3) + ')';
requestAnimationFrame(tick);
}
window.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo');
setFromPointer(e.clientX, e.clientY);
});
window.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
last = null;
setFromPointer(e.clientX, e.clientY);
});
if (reduce) {
// reduce 에서는 왜곡 없이 원본 비율로 보인다
art.style.transform = 'skewX(0deg) scale(1)';
} else {
requestAnimationFrame(tick);
}// ImageWarp — vanilla 판(run/079/demo/09-follow-image-warp/index.html) 의 React 포트.
// 커서의 매 프레임 이동량을 재 skewX 로 방향별로 비틀고 scale 로 늘렸다가,
// 속도가 죽으면 lerp 로 0으로 되돌아간다. 왜곡은 그림 안쪽에만 두고 테두리는 그대로 둔다.
// 계수·상한(±11deg · 1.14)은 vanilla 와 같은 값이다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ImageWarp.module.scss";
export type ImageWarpProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// 전역 DOM 타입에 기대지 않는다 — 포인터 이벤트에서 실제로 읽는 것만 적는다
type PtrEvt = { clientX: number; clientY: number };
const FIELD_LABEL = "새벽 풍경 그림이 커서 속도에 따라 늘어났다 되돌아오는 그림";
const HINT = "마우스를 빠르게 움직이면 그림이 늘어났다 돌아옵니다";
// lerp: 매 프레임 남은 거리의 일부만 더 가는 선형 보간
function lerp(a: number, b: number, k: number): number {
return a + (b - a) * k;
}
export default function ImageWarp({
duration = 2000,
easing = "ease-out",
color = "#fff7e6",
}: ImageWarpProps) {
const artRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement>;
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
const art = artRef.current;
if (!art) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 커서의 매 프레임 이동량(속도)을 부드럽게 쌓는다 — 멈추면 0으로 꺼진다
let vx = 0;
let vy = 0;
let sx = 0;
let sy = 0;
let last: { x: number; y: number } | null = null;
function setFromPointer(cx: number, cy: number): void {
if (last) {
vx = cx - last.x;
vy = cy - last.y;
}
last = { x: cx, y: cy };
}
function tick(): void {
// 속도를 매 프레임 12%씩만 남긴다 — 커서가 멈추면 왜곡이 스르르 풀린다
sx = lerp(sx, vx, 0.24);
sy = lerp(sy, vy, 0.24);
vx = lerp(vx, 0, 0.12);
vy = lerp(vy, 0, 0.12);
const skew = Math.max(-11, Math.min(11, sx * 0.32));
const stretch = 1 + Math.min(0.14, Math.abs(sx) * 0.011 + Math.abs(sy) * 0.009);
art.style.transform = "skewX(" + skew.toFixed(2) + "deg) scale(" + stretch.toFixed(3) + ")";
raf = requestAnimationFrame(tick);
}
function onMove(event: PtrEvt): void {
setDemo(false);
setFromPointer(event.clientX, event.clientY);
}
function onDown(event: PtrEvt): void {
setDemo(false);
last = null;
setFromPointer(event.clientX, event.clientY);
}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerdown", onDown);
let raf = 0;
if (reduce) {
// reduce 에서는 왜곡 없이 원본 비율로 보인다
art.style.transform = "skewX(0deg) scale(1)";
} else {
raf = requestAnimationFrame(tick);
}
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerdown", onDown);
cancelAnimationFrame(raf);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + (demo ? " " + styles.isDemo : "")} style={vars}>
<div className={styles.field} role="img" aria-label={FIELD_LABEL}>
<div className={styles.frame}>
<div className={styles.art} ref={artRef}>
<span className={styles.sun} aria-hidden="true"></span>
<span className={styles.ridge + " " + styles.ridgeFar} aria-hidden="true"></span>
<span className={styles.ridge + " " + styles.ridgeNear} aria-hidden="true"></span>
</div>
</div>
</div>
<p className={styles.hint}>{HINT}</p>
</div>
</main>
);
}09 Image warp tracking — velocity stretch — Measured area 5.497% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · Mouse Follow Effect CSS — 9 Cursor Chasers
The post this demo lives in
Mouse Follow Effect CSS — 9 Cursor ChasersA mouse follow effect css reads the pointer each frame and lets an element trail it by a beat — the core is one lerp lin See all demos in this post


