03이전 위치와의 거리를 계산해 미끄러지는 정렬 전환
정렬 기준을 바꾸면 각 댓글이 이전 위치와 새 위치의 세로 거리를 계산해, 그 거리만큼 자리를 옮기며 지나가는 댓글끼리는 겹치지 않게 잠깐 옆으로 비켜난다.
쓰임인기순·최신순 전환 버튼
SORT_FLIP_DURATION_MSsrt__itemapplySort

<main class="stage">
<div class="srt is-demo" id="srtRoot">
<div class="srt__head">
<button type="button" class="srt__sortBtn" id="srtSortBtn" aria-pressed="false">
<svg class="srt__sortIcon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M176 48v160H80V48Z" opacity=".2"/><path d="M117.66 170.34a8 8 0 0 1 0 11.32l-32 32a8 8 0 0 1-11.32 0l-32-32a8 8 0 0 1 11.32-11.32L72 188.69V48a8 8 0 0 1 16 0v140.69l18.34-18.35a8 8 0 0 1 11.32 0m96-96l-32-32a8 8 0 0 0-11.32 0l-32 32a8 8 0 0 0 11.32 11.32L168 67.31V208a8 8 0 0 0 16 0V67.31l18.34 18.35a8 8 0 0 0 11.32-11.32"/></g></svg>
<span class="srt__sortLabel" id="srtSortLabel">최신순</span>
</button>
</div>
<ul class="srt__list" role="list" id="srtList">
<li class="srt__item" data-id="c1" data-likes="12">
<span class="srt__avatar" aria-hidden="true"></span>
<div class="srt__body">
<p class="srt__meta"><strong class="srt__name">하늘연필</strong><span class="srt__time">방금</span></p>
<p class="srt__text">정리 잘 되어있네요.</p>
</div>
<span class="srt__likes">♥ 12</span>
</li>
<li class="srt__item" data-id="c2" data-likes="41">
<span class="srt__avatar" aria-hidden="true"></span>
<div class="srt__body">
<p class="srt__meta"><strong class="srt__name">노을풍경</strong><span class="srt__time">3분 전</span></p>
<p class="srt__text">코드 그대로 써도 되나요?</p>
</div>
<span class="srt__likes">♥ 41</span>
</li>
<li class="srt__item" data-id="c3" data-likes="7">
<span class="srt__avatar" aria-hidden="true"></span>
<div class="srt__body">
<p class="srt__meta"><strong class="srt__name">초록달빛</strong><span class="srt__time">9분 전</span></p>
<p class="srt__text">감사합니다, 큰 도움 됐어요.</p>
</div>
<span class="srt__likes">♥ 7</span>
</li>
<li class="srt__item" data-id="c4" data-likes="25">
<span class="srt__avatar" aria-hidden="true"></span>
<div class="srt__body">
<p class="srt__meta"><strong class="srt__name">바람소리</strong><span class="srt__time">22분 전</span></p>
<p class="srt__text">다음 편도 기대할게요.</p>
</div>
<span class="srt__likes">♥ 25</span>
</li>
<li class="srt__item" data-id="c5" data-likes="3">
<span class="srt__avatar" aria-hidden="true"></span>
<div class="srt__body">
<p class="srt__meta"><strong class="srt__name">모래시계</strong><span class="srt__time">41분 전</span></p>
<p class="srt__text">이 부분 설명 최고예요.</p>
</div>
<span class="srt__likes">♥ 3</span>
</li>
</ul>
<p class="srt__sr" id="srtStatus" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.srt {
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;
}
.srt__head {
display: flex;
justify-content: flex-end;
}
.srt__sortBtn {
display: inline-flex;
align-items: center;
gap: $sp-1;
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;
}
.srt__sortBtn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.srt__sortIcon { width: 13px; height: 13px; }
.srt__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
flex: 1;
}
.srt__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;
}
.srt__avatar {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: linear-gradient(160deg, $subject-mint, $subject-sky);
margin-top: 4px;
}
.srt__body { min-width: 0; flex: 1; }
.srt__meta { margin: 0 0 4px; display: flex; align-items: baseline; gap: $sp-2; }
.srt__name { color: #fff; font-size: 12px; font-weight: 700; }
.srt__time { color: rgba(255, 255, 255, .5); font-size: 10.5px; }
.srt__text { margin: 0; color: rgba(255, 255, 255, .86); font-size: 12px; line-height: 1.5; }
.srt__likes {
flex: none;
color: $subject-pink;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.srt__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.srt { padding: $sp-3; }
.srt__text, .srt__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.srt__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);
}
.srt {
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;
}
.srt__head {
display: flex;
justify-content: flex-end;
}
.srt__sortBtn {
display: inline-flex;
align-items: center;
gap: 4px;
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;
}
.srt__sortBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.srt__sortIcon {
width: 13px;
height: 13px;
}
.srt__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.srt__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;
}
.srt__avatar {
flex: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: linear-gradient(160deg, #4cd4a6, #6ec8ff);
margin-top: 2px;
}
.srt__body {
min-width: 0;
flex: 1;
}
.srt__meta {
margin: 0 0 2px;
display: flex;
align-items: baseline;
gap: 8px;
}
.srt__name {
color: #fff;
font-size: 12px;
font-weight: 700;
}
.srt__time {
color: rgba(255, 255, 255, 0.5);
font-size: 10.5px;
}
.srt__text {
margin: 0;
color: rgba(255, 255, 255, 0.86);
font-size: 12px;
line-height: 1.5;
}
.srt__likes {
flex: none;
color: #ff9ebb;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.srt__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.srt {
padding: 12px;
}
.srt__text, .srt__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.srt__item {
transition: none !important;
}
}(function () {
// 기법: FLIP(First-Last-Invert-Play). 정렬 기준을 바꾸기 전 각 항목의 getBoundingClientRect
// 를 기록(First)하고, DOM 순서를 새 정렬로 바로 바꾼 뒤(Last, 레이아웃은 이 한 번만 재계산)
// 다시 rect 를 재서 두 값의 차이(Invert)를 transform 으로 되돌려 놓았다가 0 으로 애니메이션
// (Play)한다. 실제로는 항상 최종 DOM 위치에 있고, 눈에 보이는 "이동"은 transform 뿐이다.
var SORT_FLIP_DURATION_MS = 320;
var root = document.getElementById('srtRoot');
var list = document.getElementById('srtList');
var sortBtn = document.getElementById('srtSortBtn');
var sortLabel = document.getElementById('srtSortLabel');
var status = document.getElementById('srtStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) root.classList.remove('is-demo');
var byNewestOrder = ['c1', 'c2', 'c3', 'c4', 'c5'];
var popular = false;
function sortedIds() {
if (!popular) return byNewestOrder.slice();
var items = list.querySelectorAll('.srt__item');
var arr = [];
for (var i = 0; i < items.length; i += 1) arr.push(items[i]);
arr.sort(function (a, b) { return parseInt(b.getAttribute('data-likes'), 10) - parseInt(a.getAttribute('data-likes'), 10); });
return arr.map(function (el) { return el.getAttribute('data-id'); });
}
function applySort(animate, delayMs) {
var items = list.querySelectorAll('.srt__item');
var before = {};
for (var i = 0; i < items.length; i += 1) {
before[items[i].getAttribute('data-id')] = items[i].getBoundingClientRect();
}
var order = sortedIds();
var byId = {};
for (var j = 0; j < items.length; j += 1) byId[items[j].getAttribute('data-id')] = items[j];
for (var k = 0; k < order.length; k += 1) list.appendChild(byId[order[k]]);
if (!animate || reduce) return;
for (var m = 0; m < order.length; m += 1) {
var el = byId[order[m]];
var after = el.getBoundingClientRect();
var b = before[order[m]];
var dy = b.top - after.top;
if (Math.abs(dy) < 0.5) continue;
var sideX = (m % 2 === 0 ? 1 : -1) * Math.min(14, Math.abs(dy) * 0.12);
el.style.zIndex = String(10 - m);
el.animate(
[
{ transform: 'translate(0px,' + dy + 'px)' },
{ transform: 'translate(' + sideX + 'px,' + (dy * 0.4) + 'px)' },
{ transform: 'translate(0px,0px)' }
],
{ duration: SORT_FLIP_DURATION_MS, delay: delayMs || 0, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'both' }
);
}
}
function setPopular(next, animate, delayMs) {
popular = next;
sortBtn.setAttribute('aria-pressed', String(popular));
sortLabel.textContent = popular ? '인기순' : '최신순';
status.textContent = popular ? '좋아요 많은 순으로 정렬했습니다.' : '최신 순으로 정렬했습니다.';
applySort(animate, delayMs);
}
sortBtn.addEventListener('click', function () {
root.classList.remove('is-demo');
setPopular(!popular, true, 0);
});
sortBtn.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sortBtn.click(); }
});
var seqTimer = null;
function playSequence() {
if (seqTimer) { window.clearTimeout(seqTimer); seqTimer = null; }
setPopular(false, false, 0);
setPopular(true, true, 240);
seqTimer = window.setTimeout(function () {
setPopular(false, true, 0);
}, 1080);
}
if (reduce) {
setPopular(false, false, 0);
} else {
playSequence();
window.setInterval(function () {
if (!root.classList.contains('is-demo')) return;
setPopular(!popular, true, 0);
}, 2400);
}
})();import { useEffect, useRef } from "react";
import styles from "./CommentThreadSortReorderFlip.module.scss";
// 기법: FLIP(First-Last-Invert-Play). 정렬 기준을 바꾸기 전 각 항목의 rect 를 기록(First)하고
// DOM 순서를 새 정렬로 바로 바꾼 뒤(Last) 다시 rect 를 재서 차이(Invert)를 transform 으로
// 되돌렸다가 0 으로 애니메이션(Play)한다.
const SORT_FLIP_DURATION_MS = 320;
const NEWEST_ORDER = ["c1", "c2", "c3", "c4", "c5"];
const LIKES: Record<string, number> = { c1: 12, c2: 41, c3: 7, c4: 25, c5: 3 };
export default function CommentThreadSortReorderFlip() {
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const sortBtnRef = useRef<HTMLButtonElement>(null);
const sortLabelRef = useRef<HTMLSpanElement>(null);
const statusRef = useRef<HTMLParagraphElement>(null);
const itemRefs = useRef<Record<string, HTMLLIElement | null>>({});
useEffect(() => {
const root = rootRef.current;
const list = listRef.current;
const sortBtn = sortBtnRef.current;
const sortLabel = sortLabelRef.current;
const status = statusRef.current;
if (!root || !list || !sortBtn || !sortLabel || !status) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) root.classList.remove("is-demo");
let popular = false;
function sortedIds(): string[] {
if (!popular) return NEWEST_ORDER.slice();
return NEWEST_ORDER.slice().sort((a, b) => LIKES[b] - LIKES[a]);
}
function applySort(animate: boolean, delayMs: number) {
const refs = itemRefs.current!;
const ids = NEWEST_ORDER;
const before: Record<string, DOMRect> = {};
for (const id of ids) {
const el = refs[id];
if (el) before[id] = el.getBoundingClientRect();
}
const order = sortedIds();
for (const id of order) {
const el = refs[id];
if (el) list!.appendChild(el);
}
if (!animate || reduce) return;
order.forEach((id, m) => {
const el = refs[id];
if (!el) return;
const after = el.getBoundingClientRect();
const b = before[id];
const dy = b.top - after.top;
if (Math.abs(dy) < 0.5) return;
const sideX = (m % 2 === 0 ? 1 : -1) * Math.min(14, Math.abs(dy) * 0.12);
el.style.zIndex = String(10 - m);
el.animate(
[
{ transform: `translate(0px,${dy}px)` },
{ transform: `translate(${sideX}px,${dy * 0.4}px)` },
{ transform: "translate(0px,0px)" },
],
{ duration: SORT_FLIP_DURATION_MS, delay: delayMs, easing: "cubic-bezier(.2,.7,.2,1)", fill: "both" }
);
});
}
function setPopular(next: boolean, animate: boolean, delayMs: number) {
popular = next;
sortBtn!.setAttribute("aria-pressed", String(popular));
sortLabel!.textContent = popular ? "인기순" : "최신순";
status!.textContent = popular ? "좋아요 많은 순으로 정렬했습니다." : "최신 순으로 정렬했습니다.";
applySort(animate, delayMs);
}
const onActivate = () => { root.classList.remove("is-demo"); setPopular(!popular, true, 0); };
sortBtn.addEventListener("click", onActivate);
sortBtn.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onActivate(); }
});
let seqTimer: number | undefined;
function playSequence() {
if (seqTimer) { window.clearTimeout(seqTimer); seqTimer = undefined; }
setPopular(false, false, 0);
setPopular(true, true, 240);
seqTimer = window.setTimeout(() => { setPopular(false, true, 0); }, 1080);
}
let timer: number | undefined;
if (reduce) {
setPopular(false, false, 0);
} else {
playSequence();
timer = window.setInterval(() => {
if (!root.classList.contains("is-demo")) return;
setPopular(!popular, true, 0);
}, 2400);
}
return () => { if (timer) window.clearInterval(timer); if (seqTimer) window.clearTimeout(seqTimer); };
}, []);
const comments = [
{ id: "c1", name: "하늘연필", time: "방금", text: "정리 잘 되어있네요.", likes: 12 },
{ id: "c2", name: "노을풍경", time: "3분 전", text: "코드 그대로 써도 되나요?", likes: 41 },
{ id: "c3", name: "초록달빛", time: "9분 전", text: "감사합니다, 큰 도움 됐어요.", likes: 7 },
{ id: "c4", name: "바람소리", time: "22분 전", text: "다음 편도 기대할게요.", likes: 25 },
{ id: "c5", name: "모래시계", time: "41분 전", text: "이 부분 설명 최고예요.", likes: 3 },
];
return (
<main className={styles.stage}>
<div className={`${styles.srt} is-demo`} ref={rootRef}>
<div className={styles.srt__head}>
<button type="button" className={styles.srt__sortBtn} ref={sortBtnRef} aria-pressed="false">
<svg className={styles.srt__sortIcon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M176 48v160H80V48Z" opacity=".2" />
<path d="M117.66 170.34a8 8 0 0 1 0 11.32l-32 32a8 8 0 0 1-11.32 0l-32-32a8 8 0 0 1 11.32-11.32L72 188.69V48a8 8 0 0 1 16 0v140.69l18.34-18.35a8 8 0 0 1 11.32 0m96-96l-32-32a8 8 0 0 0-11.32 0l-32 32a8 8 0 0 0 11.32 11.32L168 67.31V208a8 8 0 0 0 16 0V67.31l18.34 18.35a8 8 0 0 0 11.32-11.32" />
</g>
</svg>
<span className={styles.srt__sortLabel} ref={sortLabelRef}>최신순</span>
</button>
</div>
<ul className={styles.srt__list} role="list" ref={listRef}>
{comments.map((c) => (
<li
key={c.id}
className={styles.srt__item}
data-id={c.id}
data-likes={c.likes}
ref={(el: HTMLLIElement | null) => { itemRefs.current![c.id] = el; }}
>
<span className={styles.srt__avatar} aria-hidden="true" />
<div className={styles.srt__body}>
<p className={styles.srt__meta}>
<strong className={styles.srt__name}>{c.name}</strong>
<span className={styles.srt__time}>{c.time}</span>
</p>
<p className={styles.srt__text}>{c.text}</p>
</div>
<span className={styles.srt__likes}>♥ {c.likes}</span>
</li>
))}
</ul>
<p className={styles.srt__sr} ref={statusRef} aria-live="polite" />
</div>
</main>
);
}03 이전 위치와의 거리를 계산해 미끄러지는 정렬 전환 — 측정 누적면적 21.874% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록
이 데모가 속한 글
댓글 ui 9가지 — 대댓글 깊이부터 삭제 뒤 빈 자리까지 계산하는 목록댓글 ui는 대댓글이 몇 단계 깊이인지, 지워진 칸의 높이가 얼마인지, 고정된 뒤 얼마나 시간이 지났는지를 계산해 화면에 반영하는 댓글 목록이다. 지워진 댓글 칸이 차지했던 픽셀 값을 그대로 이어받아 아래 댓글들이 이 글의 데모 전부 보기


