03Sort Transition That Slides by a Calculated Distance From Its Old Position
Changing the sort order calculates each comment's vertical distance between its old and new position, and comments passing each other briefly step aside so they never overlap mid-move.
Best forToggle button for popular vs. newest sort
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">Newest</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">SkyPencil</strong><span class="srt__time">just now</span></p>
<p class="srt__text">This is nicely organized.</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">SunsetView</strong><span class="srt__time">3 min ago</span></p>
<p class="srt__text">Can I use the code as-is?</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">GreenMoon</strong><span class="srt__time">9 min ago</span></p>
<p class="srt__text">Thank you, this helped a lot.</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">WindSound</strong><span class="srt__time">22 min ago</span></p>
<p class="srt__text">Looking forward to the next one.</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">HourGlass</strong><span class="srt__time">41 min ago</span></p>
<p class="srt__text">This explanation is the best part.</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 ? 'Top' : 'Newest';
status.textContent = popular ? 'Sorted by most likes.' : 'Sorted by newest first.';
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 Sort Transition That Slides by a Calculated Distance From Its Old Position — Measured area 21.874% · moving frames 7/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 & tables01 Priority sort that holds two columns at once9 Data Table UI Patterns — Drag, Resize, Select
Lists & tables08 Frozen Column That Carries Its Shadow While Scrolling SidewaysInfinite Scroll Implementation — 9 List UI Basics
Lists & tables01 Leaderboard Rows That FLIP-Swap Rank PositionsLeaderboard UI Design: 9 Rank-Change List Patterns