04세 단계 폭에 철컥 걸리는 사이드바 가장자리
끄는 폭이 아이콘·소형·전체 세 눈금 근처에 오면 가까울수록 세게 끌려가고, 손을 떼면 손 속도만큼 앞을 내다본 위치에서 가장 가까운 눈금으로 흘러간다.
쓰임관리자 화면 사이드바
DETENTSPULL_RADIUSFLING_VELnearest

<main class="stage">
<div class="rh is-demo" id="rh" data-demo="1">
<div class="rh__side" data-r="side">
<p class="rh__item"><i class="rh__ico"></i><span class="rh__lab" data-r="lab">Home</span></p>
<p class="rh__item"><i class="rh__ico"></i><span class="rh__lab" data-r="lab">Inbox</span></p>
<p class="rh__item"><i class="rh__ico"></i><span class="rh__lab" data-r="lab">Tasks</span></p>
<p class="rh__item"><i class="rh__ico"></i><span class="rh__lab" data-r="lab">Setup</span></p>
<button class="rh__grip" data-r="grip" type="button" aria-label="크기 조절 손잡이"></button>
</div>
<i class="rh__tick" data-r="tick"></i><i class="rh__tick" data-r="tick"></i><i class="rh__tick" data-r="tick"></i>
<div class="rh__main"><i></i><i></i><i></i></div>
<p class="rh__meta"><span class="rh__tag">DETENT</span> <b data-r="det">1</b> <span class="rh__tag">W</span> <b data-r="w">0</b></p>
<i class="rh__cursor" data-r="cursor"></i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 사이드바 너비는 눈금 3개 중 하나로 끌려가는 스프링이다. 라벨의 투명도는 너비에서 계산한다.
@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__side {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
height: calc(100% - 32px);
width: 56px;
overflow: hidden;
padding: $sp-3 $sp-3;
border-radius: $r-card;
background: linear-gradient(180deg, $subject-sky, $subject-mint);
color: $subject-ink;
z-index: 2;
}
.rh__item {
display: flex;
align-items: center;
gap: $sp-3;
height: 30px;
margin: 0;
white-space: nowrap;
font-weight: 700;
}
.rh__ico {
flex: none;
width: 20px;
height: 20px;
border-radius: $r-xs;
background: rgba($subject-ink, .78);
}
.rh__lab { opacity: 0; }
.rh__grip {
position: absolute;
right: $sp-1;
top: 50%;
width: 12px;
height: 56px;
padding: 0;
border: 0;
border-radius: $r-pill;
background: rgba($subject-ink, .7);
transform: translateY(-50%);
cursor: col-resize;
touch-action: none;
}
.rh__main {
position: absolute;
inset: 0 0 32px 0;
display: grid;
align-content: center;
justify-items: end;
gap: $sp-3;
}
.rh__main i {
width: 40%;
height: 28px;
border-radius: $r-sm;
background: rgba($subject-cream, .16);
}
.rh__tick {
position: absolute;
left: 0;
bottom: 18px;
width: 4px;
height: 10px;
border-radius: $r-xs;
background: rgba($subject-cream, .35);
z-index: 3;
}
.rh__tick[data-on="1"] { background: $subject-pink; }
.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__side {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
height: calc(100% - 32px);
width: 56px;
overflow: hidden;
padding: 12px 12px;
border-radius: 16px;
background: linear-gradient(180deg, #6ec8ff, #4cd4a6);
color: #17141a;
z-index: 2;
}
.rh__item {
display: flex;
align-items: center;
gap: 12px;
height: 30px;
margin: 0;
white-space: nowrap;
font-weight: 700;
}
.rh__ico {
flex: none;
width: 20px;
height: 20px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.78);
}
.rh__lab {
opacity: 0;
}
.rh__grip {
position: absolute;
right: 4px;
top: 50%;
width: 12px;
height: 56px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.7);
transform: translateY(-50%);
cursor: col-resize;
touch-action: none;
}
.rh__main {
position: absolute;
inset: 0 0 32px 0;
display: grid;
align-content: center;
justify-items: end;
gap: 12px;
}
.rh__main i {
width: 40%;
height: 28px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.16);
}
.rh__tick {
position: absolute;
left: 0;
bottom: 18px;
width: 4px;
height: 10px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.35);
z-index: 3;
}
.rh__tick[data-on="1"] {
background: #ff9ebb;
}
.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();
}
}
// 기법: 끄는 중에는 눈금 가까이에서 PULL_RADIUS 안으로 들어오는 만큼 끌려가고(거리 비례),
// 놓을 때는 손 속도에 FLING 시간을 곱해 미리 내다본 위치에서 가장 가까운 눈금(NEAREST)으로 간다.
const DETENTS = [56, 144, 232];
const PULL_RADIUS = 36;
const FLING_VEL = 0.2;
const side = q("[data-r=side]");
const grip = q("[data-r=grip]");
const cursor = q("[data-r=cursor]");
const wEl = q("[data-r=w]");
const detEl = q("[data-r=det]");
const labs = Array.prototype.slice.call(root.querySelectorAll("[data-r=lab]"));
const ticks = Array.prototype.slice.call(root.querySelectorAll("[data-r=tick]"));
let sw = DETENTS[0];
let vs = 0;
let target = DETENTS[0];
let raw = DETENTS[0];
let lastRaw = DETENTS[0];
let vel = 0;
let px = 0;
let py = 0;
let gx = 0;
let dragging = false;
function limit() { return Math.min(DETENTS[2], root.clientWidth - 48); }
function nearest(x) {
let best = 0;
for (let i = 1; i < DETENTS.length; i += 1) {
if (Math.abs(DETENTS[i] - x) < Math.abs(DETENTS[best] - x))
best = i;
}
return best;
}
function pulled(x) {
const d = DETENTS[nearest(x)];
const dist = Math.abs(x - d);
if (dist >= PULL_RADIUS)
return x;
const t = 1 - dist / PULL_RADIUS;
return x + (d - x) * t * t * 0.9;
}
function release() {
if (!dragging)
return;
dragging = false;
target = Math.min(DETENTS[nearest(raw + vel * FLING_VEL)], limit());
}
function userTakes() {
stopDemo();
dragging = false;
}
function script(now) {
const ph = now % 2000;
const was = dragging;
dragging = false;
if (ph >= 200 && ph < 560) {
raw = 60 + 110 * seg2(ph, 200, 560);
dragging = true;
}
else if (ph >= 1100 && ph < 1500) {
raw = DETENTS[2] - 132 * seg2(ph, 1100, 1500);
dragging = true;
}
if (!was && dragging) {
lastRaw = raw;
vel = 0;
}
if (was && !dragging) {
dragging = true;
release();
}
px = (dragging ? raw : sw) - 4;
py = root.clientHeight * 0.5;
gx = 4;
}
function seg2(t, a, b) { return clamp((t - a) / (b - a), 0, 1); }
function snap() {
sw = target;
vs = 0;
}
function step(dt, now) {
if (demo)
script(now);
if (dragging) {
if (!demo)
raw = clamp(px + gx, 40, limit());
vel += ((raw - lastRaw) / Math.max(dt, 0.001) - vel) * 0.3;
lastRaw = raw;
target = clamp(pulled(raw), 40, limit());
}
const a = spring(sw, vs, target, 900, 2 * 0.72 * 30, dt);
sw = a[0];
vs = a[1];
}
function render() {
side.style.width = sw.toFixed(1) + "px";
const open = clamp((sw - 100) / 60, 0, 1);
for (let i = 0; i < labs.length; i += 1)
labs[i].style.opacity = open.toFixed(2);
for (let i = 0; i < ticks.length; i += 1) {
ticks[i].style.transform = "translateX(" + DETENTS[i] + "px)";
ticks[i].setAttribute("data-on", i === nearest(sw) ? "1" : "0");
}
wEl.textContent = String(Math.round(sw));
detEl.textContent = String(nearest(sw) + 1);
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 = sw - p.x;
raw = lastRaw = sw;
vel = 0;
dragging = true;
});
on(grip, "pointermove", (e) => {
if (!dragging)
return;
px = pos(e).x;
raw = clamp(px + gx, 40, limit());
if (reduce) {
target = clamp(pulled(raw), 40, limit());
sync();
}
});
on(grip, "pointerup", release);
on(grip, "pointercancel", release);
on(grip, "keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
return;
e.preventDefault();
userTakes();
const i = clamp(nearest(target) + (e.key === "ArrowRight" ? 1 : -1), 0, DETENTS.length - 1);
target = Math.min(DETENTS[i], limit());
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());
};
})();// SidebarDetentSnap - vanilla 판(04-rh-sidebar-detent-snap) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SidebarDetentSnap.module.scss";
export default function SidebarDetentSnap() {
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();
}
}
// 기법: 끄는 중에는 눈금 가까이에서 PULL_RADIUS 안으로 들어오는 만큼 끌려가고(거리 비례),
// 놓을 때는 손 속도에 FLING 시간을 곱해 미리 내다본 위치에서 가장 가까운 눈금(NEAREST)으로 간다.
const DETENTS = [56, 144, 232];
const PULL_RADIUS = 36;
const FLING_VEL = 0.2;
const side = q<HTMLElement>("[data-r=side]");
const grip = q<HTMLElement>("[data-r=grip]");
const cursor = q<HTMLElement>("[data-r=cursor]");
const wEl = q<HTMLElement>("[data-r=w]");
const detEl = q<HTMLElement>("[data-r=det]");
const labs = Array.prototype.slice.call(root.querySelectorAll("[data-r=lab]")) as HTMLElement[];
const ticks = Array.prototype.slice.call(root.querySelectorAll("[data-r=tick]")) as HTMLElement[];
let sw = DETENTS[0];
let vs = 0;
let target = DETENTS[0];
let raw = DETENTS[0];
let lastRaw = DETENTS[0];
let vel = 0;
let px = 0;
let py = 0;
let gx = 0;
let dragging = false;
function limit(): number { return Math.min(DETENTS[2], root.clientWidth - 48); }
function nearest(x: number): number {
let best = 0;
for (let i = 1; i < DETENTS.length; i += 1) {
if (Math.abs(DETENTS[i] - x) < Math.abs(DETENTS[best] - x)) best = i;
}
return best;
}
function pulled(x: number): number {
const d = DETENTS[nearest(x)];
const dist = Math.abs(x - d);
if (dist >= PULL_RADIUS) return x;
const t = 1 - dist / PULL_RADIUS;
return x + (d - x) * t * t * 0.9;
}
function release(): void {
if (!dragging) return;
dragging = false;
target = Math.min(DETENTS[nearest(raw + vel * FLING_VEL)], limit());
}
function userTakes(): void {
stopDemo();
dragging = false;
}
function script(now: number): void {
const ph = now % 2000;
const was = dragging;
dragging = false;
if (ph >= 200 && ph < 560) { raw = 60 + 110 * seg2(ph, 200, 560); dragging = true; }
else if (ph >= 1100 && ph < 1500) { raw = DETENTS[2] - 132 * seg2(ph, 1100, 1500); dragging = true; }
if (!was && dragging) { lastRaw = raw; vel = 0; }
if (was && !dragging) { dragging = true; release(); }
px = (dragging ? raw : sw) - 4;
py = root.clientHeight * 0.5;
gx = 4;
}
function seg2(t: number, a: number, b: number): number { return clamp((t - a) / (b - a), 0, 1); }
function snap(): void {
sw = target;
vs = 0;
}
function step(dt: number, now: number): void {
if (demo) script(now);
if (dragging) {
if (!demo) raw = clamp(px + gx, 40, limit());
vel += ((raw - lastRaw) / Math.max(dt, 0.001) - vel) * 0.3;
lastRaw = raw;
target = clamp(pulled(raw), 40, limit());
}
const a = spring(sw, vs, target, 900, 2 * 0.72 * 30, dt);
sw = a[0];
vs = a[1];
}
function render(): void {
side.style.width = sw.toFixed(1) + "px";
const open = clamp((sw - 100) / 60, 0, 1);
for (let i = 0; i < labs.length; i += 1) labs[i].style.opacity = open.toFixed(2);
for (let i = 0; i < ticks.length; i += 1) {
ticks[i].style.transform = "translateX(" + DETENTS[i] + "px)";
ticks[i].setAttribute("data-on", i === nearest(sw) ? "1" : "0");
}
wEl.textContent = String(Math.round(sw));
detEl.textContent = String(nearest(sw) + 1);
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 = sw - p.x;
raw = lastRaw = sw;
vel = 0;
dragging = true;
});
on(grip, "pointermove", (e: PointerEvent) => {
if (!dragging) return;
px = pos(e).x;
raw = clamp(px + gx, 40, limit());
if (reduce) { target = clamp(pulled(raw), 40, limit()); 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();
const i = clamp(nearest(target) + (e.key === "ArrowRight" ? 1 : -1), 0, DETENTS.length - 1);
target = Math.min(DETENTS[i], limit());
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__side"]} data-r="side">
<p className={styles["rh__item"]}><i className={styles["rh__ico"]}></i><span className={styles["rh__lab"]} data-r="lab">Home</span></p>
<p className={styles["rh__item"]}><i className={styles["rh__ico"]}></i><span className={styles["rh__lab"]} data-r="lab">Inbox</span></p>
<p className={styles["rh__item"]}><i className={styles["rh__ico"]}></i><span className={styles["rh__lab"]} data-r="lab">Tasks</span></p>
<p className={styles["rh__item"]}><i className={styles["rh__ico"]}></i><span className={styles["rh__lab"]} data-r="lab">Setup</span></p>
<button className={styles["rh__grip"]} data-r="grip" type="button" aria-label="크기 조절 손잡이"></button>
</div>
<i className={styles["rh__tick"]} data-r="tick"></i><i className={styles["rh__tick"]} data-r="tick"></i><i className={styles["rh__tick"]} data-r="tick"></i>
<div className={styles["rh__main"]}><i></i><i></i><i></i></div>
<p className={styles["rh__meta"]}><span className={styles["rh__tag"]}>DETENT</span> <b data-r="det">1</b> <span className={styles["rh__tag"]}>W</span> <b data-r="w">0</b></p>
<i className={styles["rh__cursor"]} data-r="cursor"></i>
</div>
</main>
);
}04 세 단계 폭에 철컥 걸리는 사이드바 가장자리 — 측정 누적면적 28.338% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · css resize handle 9가지 — 모서리를 끌면 상자가 손에 붙어 따라온다
이 데모가 속한 글
css resize handle 9가지 — 모서리를 끌면 상자가 손에 붙어 따라온다css resize handle 을 resize 속성 하나로만 두면 손잡이 모양은 못 바꾸고, 줄 높이에 안 맞고, 한계를 넘기면 그냥 막힌다 — 09번·02번·06번이 계산으로 푼다. 아래 아홉 장면은 상자·표 열 이 글의 데모 전부 보기


