04A Sidebar Edge That Clicks Into Three Detent Widths
Near the icon, compact or full detent the dragged width is pulled harder the closer it gets, and on release it glides to the detent nearest the position the hand velocity projects ahead.
Best forAdmin sidebars
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="Resize handle"></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 A Sidebar Edge That Clicks Into Three Detent Widths — Measured area 28.338% · moving frames 18/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
drag07 Edge autoscroll — an rAF loop mapping edge distance into scroll speedDrag to Reorder UI: 9 Lists, One Drop Index
drag01 Grid-Magnet Drag That Pulls an Item Into a Cell Within RangeDrag and Drop Animation CSS: 9 Physics Patterns
drag02 A Snap That Pulls Only Inside Distance and Angle ThresholdsJigsaw Puzzle JavaScript: 9 Snapping Tab Boards