07Delete Shift Where Comments Below Rise by the Deleted Row's Height
Deleting a comment calculates the height its row occupied and shrinks it to zero while fading out, and the comments below calculate that vanished distance and spring up to fill it.
Best forDeleting one's own comment
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">TealMap</strong><span class="del__time">8 min ago</span></p>
<p class="del__text">This is the first comment. Try deleting it.</p>
</div>
<button type="button" class="del__delBtn" id="delBtn1" data-target="d1" aria-label="Delete comment">
<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">SandHill</strong><span class="del__time">25 min ago</span></p>
<p class="del__text">Second comment — the shift grows larger further down.</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">WaveSound</strong><span class="del__time">46 min ago</span></p>
<p class="del__text">Third comment — watch it settle via spring damping.</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">MossGarden</strong><span class="del__time">1 hour ago</span></p>
<p class="del__text">Fourth comment — it rises by the full shifted distance.</p>
</div>
</li>
</ul>
<button type="button" class="del__resetBtn" id="delReset">Undo</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 = 'Comment deleted.';
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 = 'Comment deleted.';
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 = 'Deleted comment restored.';
}
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 Delete Shift Where Comments Below Rise by the Deleted Row's Height — Measured area 31.415% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · Comment Section UI: 9 Thread List Patterns
The post this demo lives in
Comment Section UI: 9 Thread List PatternsThe pixels a deleted comment used to fill become exactly how far the rows below rise, and reply indentation pulls its wi See all demos in this post
If you like this effect - similar demos
Lists & tables04 Empty-List Message That Appears When the Last Row Is GoneInfinite Scroll Implementation — 9 List UI Basics
Lists & tables02 A Scene Where Pulling the Paper Below the Perforation and Letting Go Tears It Off Past a Set Distance and Springs It Back Short of ItCSS Receipt: 9 Thermal Print and Tear-Off Scenes
Lists & tables08 Summary row stuck to the floor that recounts9 Data Table UI Patterns — Drag, Resize, Select