02줄 높이 배수로 달라붙는 텍스트 영역 손잡이
끄는 높이를 줄 높이로 나눠 가장 가까운 줄 수로 반올림하고, 그 높이까지 부드럽게 끌려가며 줄 수를 옆에 센다.
쓰임메모·댓글 입력창
LINE_HROUND_ROWSEASE_TOrows

<main class="stage">
<div class="rh is-demo" id="rh" data-demo="1">
<div class="rh__box" data-r="box">
<div class="rh__lines">
<i class="rh__ln"></i><i class="rh__ln"></i><i class="rh__ln"></i><i class="rh__ln"></i><i class="rh__ln"></i><i class="rh__ln"></i><i class="rh__ln"></i><i class="rh__ln"></i>
</div>
<button class="rh__grip" data-r="grip" type="button" aria-label="크기 조절 손잡이"></button>
</div>
<i class="rh__raw" data-r="raw"></i>
<p class="rh__rows"><b data-r="rows">0</b><span class="rh__tag">ROWS</span></p>
<i class="rh__cursor" data-r="cursor"></i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 상자 높이는 raw 높이를 줄 높이로 나눈 몫(반올림)에 맞춰 지수 감쇠로 이동한다.
@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; }
}
// 점선(raw)과 상자 가장자리가 어긋나 있다가 줄 수가 바뀌는 순간 붙는다.
.rh { color: $subject-ink; }
.rh__box {
position: absolute;
left: 0;
top: 0;
width: calc(100% - 64px);
height: 100px;
}
.rh__lines {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: $r-card;
background: $subject-mint;
box-shadow: 0 8px 16px rgba($subject-ink, .2);
}
.rh__ln {
position: absolute;
left: $sp-3;
height: 6px;
border-radius: $r-xs;
background: rgba($subject-ink, .55);
}
$ln-widths: 86%, 70%, 90%, 62%, 78%, 88%, 66%, 82%;
@for $i from 0 through 7 {
.rh__ln:nth-child(#{$i + 1}) {
top: calc(8px + #{$i} * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(#{nth($ln-widths, $i + 1)} - 24px);
}
}
.rh__grip {
position: absolute;
left: 50%;
bottom: -8px;
width: 64px;
height: 16px;
padding: 0;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
box-shadow: inset 0 0 0 2px $subject-mint;
transform: translateX(-50%);
cursor: ns-resize;
touch-action: none;
}
.rh__raw {
position: absolute;
left: 0;
top: 0;
width: calc(100% - 64px);
height: 0;
border-top: 2px dashed $subject-pink;
pointer-events: none;
}
.rh__rows {
position: absolute;
right: 0;
top: 0;
width: 56px;
margin: 0;
display: grid;
justify-items: center;
}
.rh__rows b { font-size: 32px; line-height: 36px; font-weight: 800; }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 {
color: #17141a;
}
.rh__box {
position: absolute;
left: 0;
top: 0;
width: calc(100% - 64px);
height: 100px;
}
.rh__lines {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 16px;
background: #4cd4a6;
box-shadow: 0 8px 16px rgba(23, 20, 26, 0.2);
}
.rh__ln {
position: absolute;
left: 12px;
height: 6px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.55);
}
.rh__ln:nth-child(1) {
top: calc(8px + 0 * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(86% - 24px);
}
.rh__ln:nth-child(2) {
top: calc(8px + 1 * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(70% - 24px);
}
.rh__ln:nth-child(3) {
top: calc(8px + 2 * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(90% - 24px);
}
.rh__ln:nth-child(4) {
top: calc(8px + 3 * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(62% - 24px);
}
.rh__ln:nth-child(5) {
top: calc(8px + 4 * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(78% - 24px);
}
.rh__ln:nth-child(6) {
top: calc(8px + 5 * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(88% - 24px);
}
.rh__ln:nth-child(7) {
top: calc(8px + 6 * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(66% - 24px);
}
.rh__ln:nth-child(8) {
top: calc(8px + 7 * var(--lh) + (var(--lh) - 6px) / 2);
width: calc(82% - 24px);
}
.rh__grip {
position: absolute;
left: 50%;
bottom: -8px;
width: 64px;
height: 16px;
padding: 0;
border: 0;
border-radius: 999px;
background: #17141a;
box-shadow: inset 0 0 0 2px #4cd4a6;
transform: translateX(-50%);
cursor: ns-resize;
touch-action: none;
}
.rh__raw {
position: absolute;
left: 0;
top: 0;
width: calc(100% - 64px);
height: 0;
border-top: 2px dashed #ff9ebb;
pointer-events: none;
}
.rh__rows {
position: absolute;
right: 0;
top: 0;
width: 56px;
margin: 0;
display: grid;
justify-items: center;
}
.rh__rows b {
font-size: 32px;
line-height: 36px;
font-weight: 800;
}(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();
}
}
// 기법: 끄는 높이(raw)를 줄 높이로 나눠 가장 가까운 줄 수로 반올림하고, 상자는 그 높이까지 지수 감쇠로 끌려간다.
// 점선은 손이 실제로 있는 높이, 상자는 줄에 맞춘 높이 - 둘의 차이가 계산이다.
const LINE_H = 28;
const PAD = 8;
const ROUND_ROWS = 1;
const EASE_TO = 14;
const box = q("[data-r=box]");
const grip = q("[data-r=grip]");
const raw = q("[data-r=raw]");
const rowsEl = q("[data-r=rows]");
const cursor = q("[data-r=cursor]");
let h = 0;
let rawH = 0;
let rows = 3;
let px = 0;
let py = 0;
let gy = 0;
let dragging = false;
let ready = false;
root.style.setProperty("--lh", LINE_H + "px");
function maxRows() {
return Math.max(2, Math.floor((root.clientHeight - 2 * PAD - 12) / LINE_H));
}
function rowsFor(height) {
const r = Math.round((height - 2 * PAD) / LINE_H / ROUND_ROWS) * ROUND_ROWS;
return clamp(r, 1, maxRows());
}
function init() {
if (ready)
return;
ready = true;
rows = Math.max(2, Math.round(maxRows() * 0.3));
h = rawH = rows * LINE_H + 2 * PAD;
}
function userTakes() {
stopDemo();
dragging = false;
init();
}
function script(now) {
const ph = now % 2000;
const lo = rows0() * LINE_H + 2 * PAD - 0.4 * LINE_H;
const hi = maxRows() * LINE_H + 2 * PAD;
const mid = Math.round(maxRows() * 0.5) * LINE_H + 2 * PAD + 0.45 * LINE_H;
let y = lo;
if (ph >= 200 && ph < 900)
y = lo + (hi - lo) * seg(ph, 200, 900);
else if (ph >= 900 && ph < 1100)
y = hi;
else if (ph >= 1100 && ph < 1800)
y = hi + (mid - hi) * seg(ph, 1100, 1800);
else if (ph >= 1800)
y = mid + (lo - mid) * seg(ph, 1800, 2000);
py = y - 4;
gy = 4;
dragging = ph >= 200;
}
function rows0() {
return Math.max(2, Math.round(maxRows() * 0.3));
}
function snap() {
init();
h = rows * LINE_H + 2 * PAD;
}
function step(dt, now) {
init();
if (demo)
script(now);
if (dragging) {
rawH = clamp(py + gy, LINE_H + 2 * PAD, root.clientHeight - 12);
rows = rowsFor(rawH);
}
h += (rows * LINE_H + 2 * PAD - h) * (1 - Math.exp(-EASE_TO * dt));
}
function render() {
box.style.height = h.toFixed(1) + "px";
raw.style.transform = "translateY(" + rawH.toFixed(1) + "px)";
rowsEl.textContent = String(rows);
cursor.style.transform = "translate(" + (root.clientWidth * 0.5 - 32).toFixed(1) + "px," + py.toFixed(1) + "px)";
}
on(grip, "pointerdown", (e) => {
userTakes();
grip.setPointerCapture(e.pointerId);
const p = pos(e);
py = p.y;
gy = h - p.y;
dragging = true;
});
on(grip, "pointermove", (e) => {
if (!dragging)
return;
py = pos(e).y;
rawH = clamp(py + gy, LINE_H + 2 * PAD, root.clientHeight - 12);
rows = rowsFor(rawH);
sync();
});
on(grip, "pointerup", () => { dragging = false; });
on(grip, "pointercancel", () => { dragging = false; });
on(grip, "keydown", (e) => {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown")
return;
e.preventDefault();
userTakes();
rows = clamp(rows + (e.key === "ArrowDown" ? 1 : -1), 1, maxRows());
rawH = rows * LINE_H + 2 * PAD;
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());
};
})();// LineSnapTextarea - vanilla 판(02-rh-line-snap-textarea) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LineSnapTextarea.module.scss";
export default function LineSnapTextarea() {
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();
}
}
// 기법: 끄는 높이(raw)를 줄 높이로 나눠 가장 가까운 줄 수로 반올림하고, 상자는 그 높이까지 지수 감쇠로 끌려간다.
// 점선은 손이 실제로 있는 높이, 상자는 줄에 맞춘 높이 - 둘의 차이가 계산이다.
const LINE_H = 28;
const PAD = 8;
const ROUND_ROWS = 1;
const EASE_TO = 14;
const box = q<HTMLElement>("[data-r=box]");
const grip = q<HTMLElement>("[data-r=grip]");
const raw = q<HTMLElement>("[data-r=raw]");
const rowsEl = q<HTMLElement>("[data-r=rows]");
const cursor = q<HTMLElement>("[data-r=cursor]");
let h = 0;
let rawH = 0;
let rows = 3;
let px = 0;
let py = 0;
let gy = 0;
let dragging = false;
let ready = false;
root.style.setProperty("--lh", LINE_H + "px");
function maxRows(): number {
return Math.max(2, Math.floor((root.clientHeight - 2 * PAD - 12) / LINE_H));
}
function rowsFor(height: number): number {
const r = Math.round((height - 2 * PAD) / LINE_H / ROUND_ROWS) * ROUND_ROWS;
return clamp(r, 1, maxRows());
}
function init(): void {
if (ready) return;
ready = true;
rows = Math.max(2, Math.round(maxRows() * 0.3));
h = rawH = rows * LINE_H + 2 * PAD;
}
function userTakes(): void {
stopDemo();
dragging = false;
init();
}
function script(now: number): void {
const ph = now % 2000;
const lo = rows0() * LINE_H + 2 * PAD - 0.4 * LINE_H;
const hi = maxRows() * LINE_H + 2 * PAD;
const mid = Math.round(maxRows() * 0.5) * LINE_H + 2 * PAD + 0.45 * LINE_H;
let y = lo;
if (ph >= 200 && ph < 900) y = lo + (hi - lo) * seg(ph, 200, 900);
else if (ph >= 900 && ph < 1100) y = hi;
else if (ph >= 1100 && ph < 1800) y = hi + (mid - hi) * seg(ph, 1100, 1800);
else if (ph >= 1800) y = mid + (lo - mid) * seg(ph, 1800, 2000);
py = y - 4;
gy = 4;
dragging = ph >= 200;
}
function rows0(): number {
return Math.max(2, Math.round(maxRows() * 0.3));
}
function snap(): void {
init();
h = rows * LINE_H + 2 * PAD;
}
function step(dt: number, now: number): void {
init();
if (demo) script(now);
if (dragging) {
rawH = clamp(py + gy, LINE_H + 2 * PAD, root.clientHeight - 12);
rows = rowsFor(rawH);
}
h += (rows * LINE_H + 2 * PAD - h) * (1 - Math.exp(-EASE_TO * dt));
}
function render(): void {
box.style.height = h.toFixed(1) + "px";
raw.style.transform = "translateY(" + rawH.toFixed(1) + "px)";
rowsEl.textContent = String(rows);
cursor.style.transform = "translate(" + (root.clientWidth * 0.5 - 32).toFixed(1) + "px," + py.toFixed(1) + "px)";
}
on(grip, "pointerdown", (e: PointerEvent) => {
userTakes();
grip.setPointerCapture(e.pointerId);
const p = pos(e);
py = p.y;
gy = h - p.y;
dragging = true;
});
on(grip, "pointermove", (e: PointerEvent) => {
if (!dragging) return;
py = pos(e).y;
rawH = clamp(py + gy, LINE_H + 2 * PAD, root.clientHeight - 12);
rows = rowsFor(rawH);
sync();
});
on(grip, "pointerup", () => { dragging = false; });
on(grip, "pointercancel", () => { dragging = false; });
on(grip, "keydown", (e: KeyboardEvent) => {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
e.preventDefault();
userTakes();
rows = clamp(rows + (e.key === "ArrowDown" ? 1 : -1), 1, maxRows());
rawH = rows * LINE_H + 2 * PAD;
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__box"]} data-r="box">
<div className={styles["rh__lines"]}>
<i className={styles["rh__ln"]}></i><i className={styles["rh__ln"]}></i><i className={styles["rh__ln"]}></i><i className={styles["rh__ln"]}></i><i className={styles["rh__ln"]}></i><i className={styles["rh__ln"]}></i><i className={styles["rh__ln"]}></i><i className={styles["rh__ln"]}></i>
</div>
<button className={styles["rh__grip"]} data-r="grip" type="button" aria-label="크기 조절 손잡이"></button>
</div>
<i className={styles["rh__raw"]} data-r="raw"></i>
<p className={styles["rh__rows"]}><b data-r="rows">0</b><span className={styles["rh__tag"]}>ROWS</span></p>
<i className={styles["rh__cursor"]} data-r="cursor"></i>
</div>
</main>
);
}02 줄 높이 배수로 달라붙는 텍스트 영역 손잡이 — 측정 누적면적 50.593% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css resize handle 9가지 — 모서리를 끌면 상자가 손에 붙어 따라온다
이 데모가 속한 글
css resize handle 9가지 — 모서리를 끌면 상자가 손에 붙어 따라온다css resize handle 을 resize 속성 하나로만 두면 손잡이 모양은 못 바꾸고, 줄 높이에 안 맞고, 한계를 넘기면 그냥 막힌다 — 09번·02번·06번이 계산으로 푼다. 아래 아홉 장면은 상자·표 열 이 글의 데모 전부 보기


