07지워진 칸의 높이만큼 아래 댓글이 밀려 올라가는 삭제 시프트
댓글을 지우면 그 칸이 차지하던 높이를 계산해 0까지 줄이며 흐려지고, 아래 있던 댓글들은 사라진 높이만큼의 거리를 계산해 스프링으로 밀려 올라온다.
쓰임본인 댓글 삭제
DELETE_SHIFT_SPRING_STIFFNESSshiftDurationremoveRow

<main class="stage">
<div class="del is-demo" id="delRoot">
<ul class="del__list" role="list" id="delList">
<li class="del__item" data-id="d1">
<span class="del__avatar" aria-hidden="true"></span>
<div class="del__body">
<p class="del__meta"><strong class="del__name">청록지도</strong><span class="del__time">8분 전</span></p>
<p class="del__text">첫 번째 댓글이에요. 이걸 지워보세요.</p>
</div>
<button type="button" class="del__delBtn" id="delBtn1" data-target="d1" aria-label="댓글 삭제">
<svg class="del__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg>
</button>
</li>
<li class="del__item" data-id="d2">
<span class="del__avatar" aria-hidden="true"></span>
<div class="del__body">
<p class="del__meta"><strong class="del__name">모래언덕</strong><span class="del__time">25분 전</span></p>
<p class="del__text">두 번째 댓글 — 아래로 갈수록 밀리는 폭이 커요.</p>
</div>
</li>
<li class="del__item" data-id="d3">
<span class="del__avatar" aria-hidden="true"></span>
<div class="del__body">
<p class="del__meta"><strong class="del__name">파도소리</strong><span class="del__time">46분 전</span></p>
<p class="del__text">세 번째 댓글 — 스프링 감쇠로 멎는 걸 보세요.</p>
</div>
</li>
<li class="del__item" data-id="d4">
<span class="del__avatar" aria-hidden="true"></span>
<div class="del__body">
<p class="del__meta"><strong class="del__name">이끼정원</strong><span class="del__time">1시간 전</span></p>
<p class="del__text">네 번째 댓글 — 마지막까지 밀린 거리만큼 올라옵니다.</p>
</div>
</li>
</ul>
<button type="button" class="del__resetBtn" id="delReset">되돌리기</button>
<p class="del__sr" id="delStatus" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.del {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $tick-panel;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
box-sizing: border-box;
padding: $sp-4;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.del__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
flex: 1;
}
.del__item {
display: flex;
align-items: flex-start;
gap: $sp-2;
padding: $sp-2 $sp-3;
background: rgba(255, 255, 255, .05);
border-radius: $r-sm;
will-change: transform, height;
}
.del__avatar {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(160deg, $subject-lilac, $subject-pink);
}
.del__body { min-width: 0; flex: 1; }
.del__meta { margin: 0 0 4px; display: flex; align-items: baseline; gap: $sp-2; }
.del__name { color: #fff; font-size: 12px; font-weight: 700; }
.del__time { color: rgba(255, 255, 255, .5); font-size: 10.5px; }
.del__text { margin: 0; color: rgba(255, 255, 255, .86); font-size: 12px; line-height: 1.5; }
.del__delBtn {
flex: none;
border: 0;
background: rgba(255, 255, 255, .07);
color: rgba(255, 255, 255, .55);
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: $r-xs;
cursor: pointer;
&:hover { color: $error; background: rgba(194, 54, 26, .14); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
&:disabled { opacity: .25; cursor: default; }
}
.del__icon { width: 14px; height: 14px; }
.del__resetBtn {
align-self: flex-end;
border: 0;
background: rgba(255, 255, 255, .07);
color: $subject-blue-light;
font-size: 11.5px;
font-weight: 600;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
cursor: pointer;
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
.del__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.del { padding: $sp-3; }
.del__text, .del__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.del__item { transition: none !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);
}
.del {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #231f28;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
box-sizing: border-box;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.del__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.del__item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
will-change: transform, height;
}
.del__avatar {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(160deg, #b9a0ff, #ff9ebb);
}
.del__body {
min-width: 0;
flex: 1;
}
.del__meta {
margin: 0 0 2px;
display: flex;
align-items: baseline;
gap: 8px;
}
.del__name {
color: #fff;
font-size: 12px;
font-weight: 700;
}
.del__time {
color: rgba(255, 255, 255, 0.5);
font-size: 10.5px;
}
.del__text {
margin: 0;
color: rgba(255, 255, 255, 0.86);
font-size: 12px;
line-height: 1.5;
}
.del__delBtn {
flex: none;
border: 0;
background: rgba(255, 255, 255, 0.07);
color: rgba(255, 255, 255, 0.55);
width: 26px;
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
}
.del__delBtn:hover {
color: #c2361a;
background: rgba(194, 54, 26, 0.14);
}
.del__delBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.del__delBtn:disabled {
opacity: 0.25;
cursor: default;
}
.del__icon {
width: 14px;
height: 14px;
}
.del__resetBtn {
align-self: flex-end;
border: 0;
background: rgba(255, 255, 255, 0.07);
color: #5b8cf8;
font-size: 11.5px;
font-weight: 600;
padding: 4px 12px;
border-radius: 999px;
cursor: pointer;
}
.del__resetBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.del__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.del {
padding: 12px;
}
.del__text, .del__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.del__item {
transition: none !important;
}
}(function () {
// 기법: 삭제 대상 행의 offsetHeight 를 실측해 그 픽셀값만큼 height 를 0 으로 접으며 사라지게
// 하고, 아래 행들은 First(삭제 전 rect)-Last(삭제 후 rect) 차이를 계산해 그 거리만큼
// 스프링 이징으로 transform 이동 뒤 0 으로 멎는다(FLIP + 스프링 감쇠).
// 스프링 강성 DELETE_SHIFT_SPRING_STIFFNESS 가 낮을수록(멀미는) 이동 시간이 길어진다.
var DELETE_SHIFT_SPRING_STIFFNESS = 220;
// $ease-lerp-settle 곡선(site/static/demo-tokens.scss)과 같은 샘플 — 1 을 넘겼다가(최대 1.126) 되돌아온다.
var SPRING_SETTLE_EASING = 'linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)';
var root = document.getElementById('delRoot');
var list = document.getElementById('delList');
var status = document.getElementById('delStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
function shiftDuration(gapPx) {
// 강성이 높을수록 빨리 멎는다. 이동 거리가 커도 시간이 살짝 늘어난다.
return Math.round(60 + 100000 / DELETE_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
function itemsAfter(id) {
var all = list.querySelectorAll('.del__item');
var seen = false;
var after = [];
for (var i = 0; i < all.length; i += 1) {
if (all[i].getAttribute('data-id') === id) { seen = true; continue; }
if (seen && all[i].getBoundingClientRect().height > 0) after.push(all[i]);
}
return after;
}
function removeRow(id, animate, delayMs) {
var row = list.querySelector('.del__item[data-id="' + id + '"]');
if (!row || row.getBoundingClientRect().height === 0) return;
var btn = row.querySelector('.del__delBtn');
// First: 아래 행들의 현재 위치.
var below = itemsAfter(id);
var before = below.map(function (el) { return el.getBoundingClientRect().top; });
var gap = row.offsetHeight + 8; // 행 높이 + 리스트 gap
if (!animate || reduce) {
row.style.display = 'none';
status.textContent = '댓글을 삭제했습니다.';
return;
}
// 삭제 행 접기: 실측 높이에서 0 으로, 내용은 페이드.
var h = row.offsetHeight;
row.style.height = h + 'px';
row.style.overflow = 'hidden';
row.style.boxSizing = 'border-box';
row.style.marginTop = '0px';
row.style.marginBottom = '0px';
row.animate(
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
{ duration: 240, delay: delayMs || 0, easing: 'ease-in', fill: 'forwards' }
).onfinish = function () { row.style.display = 'none'; };
// Last: 접힌 만큼 아래 행들이 실제로 올라온 위치와의 차이를 계산해 스프링으로 메운다.
// (여기서는 접기가 display:none 이 아니라 height:0 로 끝나므로, gap 픽셀만큼 아래 행들이
// 위로 밀린다 — 그 이동량을 transform 으로 역보정했다가 스프링으로 풀어준다.)
var dur = shiftDuration(gap);
below.forEach(function (el, i) {
var b = before[i];
el.animate(
[
{ transform: 'translateY(0px)' },
{ transform: 'translateY(' + gap + 'px)' },
{ transform: 'translateY(0px)' }
],
{ duration: dur, delay: (delayMs || 0) + 240, easing: SPRING_SETTLE_EASING, fill: 'both' }
);
});
status.textContent = '댓글을 삭제했습니다.';
if (btn) btn.disabled = true;
}
function restoreRows(animate) {
var rows = list.querySelectorAll('.del__item');
for (var i = 0; i < rows.length; i += 1) {
rows[i].getAnimations().forEach(function (a) { a.cancel(); });
rows[i].style.display = '';
rows[i].style.height = '';
rows[i].style.overflow = '';
rows[i].style.marginTop = '';
rows[i].style.marginBottom = '';
var btn = rows[i].querySelector('.del__delBtn');
if (btn) btn.disabled = false;
}
status.textContent = '삭제한 댓글을 되돌렸습니다.';
}
document.getElementById('delBtn1').addEventListener('click', function () {
root.classList.remove('is-demo');
removeRow('d1', true, 0);
});
document.getElementById('delBtn1').addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); document.getElementById('delBtn1').click(); }
});
document.getElementById('delReset').addEventListener('click', function () {
root.classList.remove('is-demo');
restoreRows(false);
});
document.getElementById('delReset').addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); document.getElementById('delReset').click(); }
});
if (reduce) {
// 초기 상태 그대로 — 입력 즉시 반영.
} else {
removeRow('d1', true, 220);
}
})();import { useEffect, useRef } from "react";
import styles from "./CommentThreadDeleteCollapseShift.module.scss";
// 기법: 삭제 대상 행의 offsetHeight 를 실측해 그 픽셀값만큼 height 를 0 으로 접으며 사라지게
// 하고, 아래 행들은 First(삭제 전)-Last(삭제 후) 차이를 계산해 그 거리만큼 스프링 이징으로
// transform 이동 뒤 0 으로 멎는다(FLIP + 스프링 감쇠).
// 스프링 강성 DELETE_SHIFT_SPRING_STIFFNESS 가 낮을수록 이동 시간이 길어진다.
const DELETE_SHIFT_SPRING_STIFFNESS = 220;
// $ease-lerp-settle 곡선(site/static/demo-tokens.scss)과 같은 샘플 — 1 을 넘겼다가(최대 1.126) 되독아온다.
const SPRING_SETTLE_EASING =
"linear(0.0, 0.051, 0.176, 0.341, 0.518, 0.685, 0.831, 0.947, 1.032, 1.087, 1.117, 1.126, 1.121, 1.105, 1.085, 1.062, 1.041, 1.022, 1.008, 0.997, 1)";
export default function CommentThreadDeleteCollapseShift() {
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const resetRef = useRef<HTMLButtonElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const list = listRef.current;
const btn = btnRef.current;
const reset = resetRef.current;
const status = statusRef.current;
if (!root || !list || !btn || !reset || !status) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
function shiftDuration(gapPx: number): number {
return Math.round(60 + 100000 / DELETE_SHIFT_SPRING_STIFFNESS + Math.min(240, gapPx * 0.55));
}
function itemsAfter(id: string): HTMLElement[] {
const all = Array.from(list!.querySelectorAll<HTMLElement>(".del__item"));
const seen = all.findIndex((el) => el.getAttribute("data-id") === id);
return all.slice(seen + 1).filter((el) => el.getBoundingClientRect().height > 0);
}
function removeRow(id: string, animate: boolean, delayMs: number) {
const row = list!.querySelector<HTMLElement>(`.del__item[data-id="${id}"]`);
if (!row || row.getBoundingClientRect().height === 0) return;
const delBtn = row.querySelector<HTMLButtonElement>(".del__delBtn");
const below = itemsAfter(id);
const before = below.map((el) => el.getBoundingClientRect().top);
const gap = row.offsetHeight + 8;
if (!animate || reduce) {
row.style.display = "none";
status!.textContent = "댓글을 삭제했습니다.";
return;
}
const h = row.offsetHeight;
row.style.height = h + "px";
row.style.overflow = "hidden";
row.style.boxSizing = "border-box";
row.style.marginTop = "0px";
row.style.marginBottom = "0px";
const collapse = row.animate(
[{ height: h + "px", opacity: 1 }, { height: "0px", opacity: 0 }],
{ duration: 240, delay: delayMs, easing: "ease-in", fill: "forwards" }
);
collapse.onfinish = () => { row.style.display = "none"; };
const dur = shiftDuration(gap);
below.forEach((el, i) => {
el.animate(
[
{ transform: "translateY(0px)" },
{ transform: `translateY(${gap}px)` },
{ transform: "translateY(0px)" },
],
{ duration: dur, delay: delayMs + 240, easing: SPRING_SETTLE_EASING, fill: "both" }
);
});
status!.textContent = "댓글을 삭제했습니다.";
if (delBtn) delBtn.disabled = true;
}
function restoreRows() {
const rows = list!.querySelectorAll<HTMLElement>(".del__item");
rows.forEach((row) => {
row.getAnimations().forEach((a) => a.cancel());
row.style.display = "";
row.style.height = "";
row.style.overflow = "";
row.style.marginTop = "";
row.style.marginBottom = "";
const b = row.querySelector<HTMLButtonElement>(".del__delBtn");
if (b) b.disabled = false;
});
status!.textContent = "삭제한 댓글을 되돌렸습니다.";
}
const onActivate = () => { root.classList.remove("is-demo"); removeRow("d1", true, 0); };
btn.addEventListener("click", onActivate);
btn.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onActivate(); }
});
const onReset = () => { root.classList.remove("is-demo"); restoreRows(); };
reset.addEventListener("click", onReset);
reset.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onReset(); }
});
if (!reduce) removeRow("d1", true, 220);
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.del} is-demo`} ref={rootRef}>
<ul className={styles.del__list} role="list" ref={listRef}>
<li className={`del__item ${styles.del__item}`} data-id="d1">
<span className={styles.del__avatar} aria-hidden="true" />
<div className={styles.del__body}>
<p className={styles.del__meta}>
<strong className={styles.del__name}>청록지도</strong>
<span className={styles.del__time}>8분 전</span>
</p>
<p className={styles.del__text}>첫 번째 댓글이에요. 이걸 지워보세요.</p>
</div>
<button type="button" className={`del__delBtn ${styles.del__delBtn}`} ref={btnRef} data-target="d1" aria-label="댓글 삭제">
<svg className={styles.del__icon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" />
<path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" />
</g>
</svg>
</button>
</li>
<li className={`del__item ${styles.del__item}`} data-id="d2">
<span className={styles.del__avatar} aria-hidden="true" />
<div className={styles.del__body}>
<p className={styles.del__meta}>
<strong className={styles.del__name}>모래언덕</strong>
<span className={styles.del__time}>25분 전</span>
</p>
<p className={styles.del__text}>두 번째 댓글 — 아래로 갈수록 밀리는 폭이 커요.</p>
</div>
</li>
<li className={`del__item ${styles.del__item}`} data-id="d3">
<span className={styles.del__avatar} aria-hidden="true" />
<div className={styles.del__body}>
<p className={styles.del__meta}>
<strong className={styles.del__name}>파도소리</strong>
<span className={styles.del__time}>46분 전</span>
</p>
<p className={styles.del__text}>세 번째 댓글 — 스프링 감쇠로 멎는 걸 보세요.</p>
</div>
</li>
<li className={`del__item ${styles.del__item}`} data-id="d4">
<span className={styles.del__avatar} aria-hidden="true" />
<div className={styles.del__body}>
<p className={styles.del__meta}>
<strong className={styles.del__name}>이끼정원</strong>
<span className={styles.del__time}>1시간 전</span>
</p>
<p className={styles.del__text}>네 번째 댓글 — 마지막까지 밀린 거리만큼 올라옵니다.</p>
</div>
</li>
</ul>
<button type="button" className={styles.del__resetBtn} ref={resetRef}>되돌리기</button>
<p className={styles.del__sr} ref={statusRef} aria-live="polite" />
</div>
</main>
);
}07 지워진 칸의 높이만큼 아래 댓글이 밀려 올라가는 삭제 시프트 — 측정 누적면적 31.415% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록
이 데모가 속한 글
댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록댓글 ui는 대댓글이 몇 단계 깊이인지, 지워진 칸의 높이가 얼마인지, 고정된 뒤 얼마나 시간이 지났는지를 계산해 화면에 반영하는 댓글 목록이다. 지워진 댓글 칸이 차지했던 픽셀 값을 그대로 이어받아 아래 댓글들이 이 글의 데모 전부 보기


