06A Box That Resists Harder Past the Limit Then Springs Back
Distance past the maximum is squashed by a square root to shrink the stretch, and releasing sends it back to the limit with a damped oscillation.
Best forPanels with a hard limit
MAX_RATIORESISTSPRING_BACKstretch

<main class="stage">
<div class="rh is-demo" id="rh" data-demo="1">
<div class="rh__panel" data-r="panel">
<i class="rh__stripe"></i>
<button class="rh__grip" data-r="grip" type="button" aria-label="Resize handle"></button>
</div>
<i class="rh__limit" data-r="limit"></i>
<p class="rh__meta"><span class="rh__tag">MAX</span> <b data-r="max">0</b> <span class="rh__tag">OVER</span> <b data-r="over">0</b></p>
<i class="rh__cursor" data-r="cursor"></i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 늘어난 양(--over)이 색과 줄무늬를 같이 끈다. 폭은 JS 스프링이 만든다.
@include stage($stage-ink);
.rh {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
color: $subject-cream;
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: $tick-mute; 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-pink; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.rh__cursor { display: none; }
}
.rh__panel {
--over: 0;
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
height: calc(100% - 24px);
overflow: hidden;
border-radius: $r-card;
background: color-mix(in srgb, #{$subject-mint}, #{$subject-pink} calc(var(--over) * 100%));
}
.rh__stripe {
position: absolute;
inset: 0;
opacity: var(--over);
background: repeating-linear-gradient(90deg, transparent 0 10px, rgba($subject-ink, .18) 10px 12px);
}
.rh__grip {
position: absolute;
right: $sp-2;
top: 50%;
width: 12px;
height: 64px;
padding: 0;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
transform: translateY(-50%);
cursor: ew-resize;
touch-action: none;
}
.rh__limit {
position: absolute;
left: 0;
top: 0;
height: calc(100% - 24px);
border-left: 2px dashed $subject-cream;
pointer-events: none;
}
.rh__meta { position: absolute; left: 0; bottom: 0; margin: 0; }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);
}
.rh {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
color: #fff7e6;
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: #9a93a6;
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 #ff9ebb;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.rh__cursor {
display: none;
}
}
.rh__panel {
--over: 0;
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
height: calc(100% - 24px);
overflow: hidden;
border-radius: 16px;
background: color-mix(in srgb, #4cd4a6, #ff9ebb calc(var(--over) * 100%));
}
.rh__stripe {
position: absolute;
inset: 0;
opacity: var(--over);
background: repeating-linear-gradient(90deg, transparent 0 10px, rgba(23, 20, 26, 0.18) 10px 12px);
}
.rh__grip {
position: absolute;
right: 8px;
top: 50%;
width: 12px;
height: 64px;
padding: 0;
border: 0;
border-radius: 999px;
background: #17141a;
transform: translateY(-50%);
cursor: ew-resize;
touch-action: none;
}
.rh__limit {
position: absolute;
left: 0;
top: 0;
height: calc(100% - 24px);
border-left: 2px dashed #fff7e6;
pointer-events: none;
}
.rh__meta {
position: absolute;
left: 0;
bottom: 0;
margin: 0;
}(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();
}
}
// 기법: 한계(MAX_RATIO) 넘은 만큼을 제곱근으로 눌러 저항을 만든다 - 더 끌수록 같은 거리에서 덜 늘어난다.
// 놓으면 목표는 한계선이고, 감쇠비 0.35 의 스프링이 한계선을 지나쳤다 돌아오며 진동한다.
const MAX_RATIO = 0.64;
const RESIST = 5;
const SPRING_BACK = 16;
const MIN_W = 56;
const panel = q("[data-r=panel]");
const grip = q("[data-r=grip]");
const limit = q("[data-r=limit]");
const maxEl = q("[data-r=max]");
const overEl = q("[data-r=over]");
const cursor = q("[data-r=cursor]");
let w = 0;
let vw = 0;
let target = 0;
let raw = 0;
let px = 0;
let py = 0;
let gx = 0;
let grabW = 0;
let dragging = false;
let ready = false;
function maxW() { return root.clientWidth * MAX_RATIO; }
function stretch(r) {
const m = maxW();
return r <= m ? r : m + RESIST * Math.sqrt(r - m);
}
function init() {
if (ready)
return;
ready = true;
target = raw = w = root.clientWidth * 0.4;
}
function release() {
if (!dragging)
return;
dragging = false;
target = Math.min(raw, maxW());
}
function userTakes() {
stopDemo();
dragging = false;
init();
}
function script(now) {
const W = root.clientWidth;
const ph = now % 2000;
const was = dragging;
const r0 = W * 0.4;
dragging = false;
if (ph >= 200 && ph < 1000) {
dragging = true;
raw = r0 + (W - r0) * seg(ph, 200, 700);
}
else if (ph >= 1400 && ph < 1960) {
dragging = true;
if (!was)
grabW = w;
raw = grabW + (r0 - grabW) * seg(ph, 1400, 1960);
}
if (was && !dragging) {
dragging = true;
release();
}
px = (dragging ? raw : w) - 8;
py = root.clientHeight * 0.5;
gx = 8;
}
function snap() {
init();
w = target;
vw = 0;
}
function step(dt, now) {
init();
if (demo)
script(now);
if (dragging) {
if (!demo)
raw = clamp(px + gx, MIN_W, root.clientWidth);
target = clamp(stretch(raw), MIN_W, root.clientWidth);
const a = spring(w, vw, target, 1600, 80, dt);
w = a[0];
vw = a[1];
}
else {
const a = spring(w, vw, target, SPRING_BACK * SPRING_BACK, 2 * 0.35 * SPRING_BACK, dt);
w = a[0];
vw = a[1];
}
}
function render() {
const m = maxW();
const over = Math.max(0, w - m);
const span = Math.max(1, RESIST * Math.sqrt(Math.max(1, root.clientWidth - m)));
panel.style.width = w.toFixed(1) + "px";
panel.style.setProperty("--over", clamp(over / span, 0, 1).toFixed(3));
limit.style.transform = "translateX(" + m.toFixed(1) + "px)";
maxEl.textContent = String(Math.round(m));
overEl.textContent = String(Math.round(over));
cursor.style.transform = "translate(" + px.toFixed(1) + "px," + py.toFixed(1) + "px)";
}
on(grip, "pointerdown", (e) => {
userTakes();
grip.setPointerCapture(e.pointerId);
const p = pos(e);
px = p.x;
py = p.y;
gx = w - p.x;
raw = w;
dragging = true;
});
on(grip, "pointermove", (e) => {
if (!dragging)
return;
px = pos(e).x;
raw = clamp(px + gx, MIN_W, root.clientWidth);
if (reduce) {
target = clamp(Math.min(stretch(raw), maxW()), MIN_W, root.clientWidth);
sync();
}
});
on(grip, "pointerup", release);
on(grip, "pointercancel", release);
on(grip, "keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
return;
e.preventDefault();
userTakes();
target = clamp(target + (e.key === "ArrowRight" ? 16 : -16), MIN_W, maxW());
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());
};
})();// RubberBandLimit - vanilla 판(06-rh-rubber-band-limit) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RubberBandLimit.module.scss";
export default function RubberBandLimit() {
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();
}
}
// 기법: 한계(MAX_RATIO) 넘은 만큼을 제곱근으로 눌러 저항을 만든다 - 더 끌수록 같은 거리에서 덜 늘어난다.
// 놓으면 목표는 한계선이고, 감쇠비 0.35 의 스프링이 한계선을 지나쳤다 돌아오며 진동한다.
const MAX_RATIO = 0.64;
const RESIST = 5;
const SPRING_BACK = 16;
const MIN_W = 56;
const panel = q<HTMLElement>("[data-r=panel]");
const grip = q<HTMLElement>("[data-r=grip]");
const limit = q<HTMLElement>("[data-r=limit]");
const maxEl = q<HTMLElement>("[data-r=max]");
const overEl = q<HTMLElement>("[data-r=over]");
const cursor = q<HTMLElement>("[data-r=cursor]");
let w = 0;
let vw = 0;
let target = 0;
let raw = 0;
let px = 0;
let py = 0;
let gx = 0;
let grabW = 0;
let dragging = false;
let ready = false;
function maxW(): number { return root.clientWidth * MAX_RATIO; }
function stretch(r: number): number {
const m = maxW();
return r <= m ? r : m + RESIST * Math.sqrt(r - m);
}
function init(): void {
if (ready) return;
ready = true;
target = raw = w = root.clientWidth * 0.4;
}
function release(): void {
if (!dragging) return;
dragging = false;
target = Math.min(raw, maxW());
}
function userTakes(): void {
stopDemo();
dragging = false;
init();
}
function script(now: number): void {
const W = root.clientWidth;
const ph = now % 2000;
const was = dragging;
const r0 = W * 0.4;
dragging = false;
if (ph >= 200 && ph < 1000) {
dragging = true;
raw = r0 + (W - r0) * seg(ph, 200, 700);
} else if (ph >= 1400 && ph < 1960) {
dragging = true;
if (!was) grabW = w;
raw = grabW + (r0 - grabW) * seg(ph, 1400, 1960);
}
if (was && !dragging) { dragging = true; release(); }
px = (dragging ? raw : w) - 8;
py = root.clientHeight * 0.5;
gx = 8;
}
function snap(): void {
init();
w = target;
vw = 0;
}
function step(dt: number, now: number): void {
init();
if (demo) script(now);
if (dragging) {
if (!demo) raw = clamp(px + gx, MIN_W, root.clientWidth);
target = clamp(stretch(raw), MIN_W, root.clientWidth);
const a = spring(w, vw, target, 1600, 80, dt);
w = a[0];
vw = a[1];
} else {
const a = spring(w, vw, target, SPRING_BACK * SPRING_BACK, 2 * 0.35 * SPRING_BACK, dt);
w = a[0];
vw = a[1];
}
}
function render(): void {
const m = maxW();
const over = Math.max(0, w - m);
const span = Math.max(1, RESIST * Math.sqrt(Math.max(1, root.clientWidth - m)));
panel.style.width = w.toFixed(1) + "px";
panel.style.setProperty("--over", clamp(over / span, 0, 1).toFixed(3));
limit.style.transform = "translateX(" + m.toFixed(1) + "px)";
maxEl.textContent = String(Math.round(m));
overEl.textContent = String(Math.round(over));
cursor.style.transform = "translate(" + px.toFixed(1) + "px," + py.toFixed(1) + "px)";
}
on(grip, "pointerdown", (e: PointerEvent) => {
userTakes();
grip.setPointerCapture(e.pointerId);
const p = pos(e);
px = p.x;
py = p.y;
gx = w - p.x;
raw = w;
dragging = true;
});
on(grip, "pointermove", (e: PointerEvent) => {
if (!dragging) return;
px = pos(e).x;
raw = clamp(px + gx, MIN_W, root.clientWidth);
if (reduce) { target = clamp(Math.min(stretch(raw), maxW()), MIN_W, root.clientWidth); sync(); }
});
on(grip, "pointerup", release);
on(grip, "pointercancel", release);
on(grip, "keydown", (e: KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
userTakes();
target = clamp(target + (e.key === "ArrowRight" ? 16 : -16), MIN_W, maxW());
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__panel"]} data-r="panel">
<i className={styles["rh__stripe"]}></i>
<button className={styles["rh__grip"]} data-r="grip" type="button" aria-label="크기 조절 손잡이"></button>
</div>
<i className={styles["rh__limit"]} data-r="limit"></i>
<p className={styles["rh__meta"]}><span className={styles["rh__tag"]}>MAX</span> <b data-r="max">0</b> <span className={styles["rh__tag"]}>OVER</span> <b data-r="over">0</b></p>
<i className={styles["rh__cursor"]} data-r="cursor"></i>
</div>
</main>
);
}06 A Box That Resists Harder Past the Limit Then Springs Back — Measured area 55.13% · moving frames 22/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
drag03 Rubber Boundary That Stretches and Snaps Back Past the EdgeDrag and Drop Animation CSS: 9 Physics Patterns
drag03 Multi-file — a group that counts entries and computes filled slots versus the remainderDrag and Drop File Upload UI: 9 Computed Dropzones
drag06 An Auto Solver That Starts from the BorderJigsaw Puzzle JavaScript: 9 Snapping Tab Boards