08정렬 순서 값이 바뀔 때 미끄러지는 전환
정렬 기준을 바꾸면 카드마다 새 순서 값이 매겨지고, 화면에서 사라졌다 다시 나타나는 대신 각 카드가 이전 자리에서 새 자리까지 미끄러지듯 이동하는 모습으로 순서가 바뀐다.
쓰임가격순·인기순 정렬 목록, 칸반 보드 재배치
viewTransitionName::view-transition-group(*)appendChild

<main class="stage">
<div class="lr is-demo" id="lr">
<button type="button" class="lr__sort" id="lrSort" aria-label="가격순 정렬 뒤집기">가격 오름차순</button>
<ul class="lr__list" id="lrList">
<li class="lr__item" data-id="a"><span class="lr__name">알파 상품</span><span class="lr__price" data-v="12000">12,000</span></li>
<li class="lr__item" data-id="b"><span class="lr__name">베타 상품</span><span class="lr__price" data-v="4000">4,000</span></li>
<li class="lr__item" data-id="c"><span class="lr__name">감마 상품</span><span class="lr__price" data-v="19000">19,000</span></li>
<li class="lr__item" data-id="d"><span class="lr__name">델타 상품</span><span class="lr__price" data-v="8000">8,000</span></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-paper);
.lr { width: min(100%, 300px); display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.lr__sort {
border: none; border-radius: $r-pill; cursor: pointer; background: $color;
color: $subject-cream; font: 700 11px/1 -apple-system, sans-serif; padding: $sp-2 $sp-4;
}
.lr__sort:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.lr__list { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: $sp-2; }
.lr__item {
display: flex; align-items: center; justify-content: space-between; box-sizing: border-box;
background: $subject-cream; border-radius: $r-control; padding: $sp-2 $sp-3;
}
.lr__name { font: 600 12px/1 -apple-system, sans-serif; color: $subject-ink; }
.lr__price { font: 700 12px/1 "Space Mono", monospace; color: $color; }
// 실사용 원리: 각 li 가 lr-item-a…d 라는 자기만의 이름표 값을 갖고 있으므로, 정렬 뒤 DOM 순서를
// 다시 붙여도(appendChild) 브라우저가 같은 이름표를 가진 옛·새 위치를 짝지어 미끄러뜨린다 —
// 사라졌다 나타나는 대신 카드 하나하나가 자기 새 자리까지 이동해 보인다.
::view-transition-group(*) {
animation-duration: $duration;
animation-timing-function: $easing;
}
@media (max-width: 340px) {
.lr__item { padding: $sp-1 $sp-2; }
.lr__name, .lr__price { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}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);
}
.lr {
width: min(100%, 300px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.lr__sort {
border: none;
border-radius: 999px;
cursor: pointer;
background: #0a4fe8;
color: #fff7e6;
font: 700 11px/1 -apple-system, sans-serif;
padding: 8px 16px;
}
.lr__sort:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.lr__list {
list-style: none;
margin: 0;
padding: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.lr__item {
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
background: #fff7e6;
border-radius: 12px;
padding: 8px 12px;
}
.lr__name {
font: 600 12px/1 -apple-system, sans-serif;
color: #17141a;
}
.lr__price {
font: 700 12px/1 "Space Mono", monospace;
color: #0a4fe8;
}
::view-transition-group(*) {
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
@media (max-width: 340px) {
.lr__item {
padding: 4px 8px;
}
.lr__name, .lr__price {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}(function () {
var SUPPORTS_VT = 'startViewTransition' in document;
var root = document.getElementById('lr');
var sortBtn = document.getElementById('lrSort');
var list = document.getElementById('lrList');
var items = Array.prototype.slice.call(list.children);
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 카드마다 데이터 id 값으로 고유한 이름표 값을 지어 붙인다 — 순서가 바뀌어도
// 같은 카드는 같은 이름표를 유지하니 브라우저가 "이 카드가 저 자리로 이동했다"를 알 수 있다.
items.forEach(function (el) {
el.style.viewTransitionName = 'lr-item-' + el.dataset.id;
});
var asc = true;
var isDemo = true;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function render() {
var sorted = items.slice().sort(function (a, b) {
var av = Number(a.querySelector('.lr__price').dataset.v);
var bv = Number(b.querySelector('.lr__price').dataset.v);
return asc ? av - bv : bv - av;
});
sorted.forEach(function (el) { list.appendChild(el); });
sortBtn.textContent = asc ? '가격 오름차순' : '가격 내림차순';
}
function change(fn) {
if (SUPPORTS_VT) { document.startViewTransition(fn); }
else { fn(); }
}
function toggleSort() {
asc = !asc;
change(render);
}
sortBtn.addEventListener('click', function () { human(); toggleSort(); });
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { human(); e.preventDefault(); toggleSort(); }
});
var HOLD_MS = 1000;
function loop() {
if (!isDemo) return;
toggleSort();
window.setTimeout(loop, HOLD_MS);
}
if (!reduce) {
window.setTimeout(loop, HOLD_MS);
} else {
root.classList.remove('is-demo');
}
})();// ListReorder — vanilla 판(run/401/demo/08-view-transition-list-reorder/index.html) 의 React 포트.
// 계산 겹: 카드마다 데이터 id 값으로 고유한 이름표 값을 지어 붙인다 — 순서가 바뀌어도 같은 카드는
// 같은 이름표를 유지하니 브라우저가 "이 카드가 저 자리로 이동했다"를 알 수 있다.
import { useEffect, useRef } from "react";
import styles from "./ListReorder.module.scss";
type Box<T> = { current: T };
const ITEMS = [
{ id: "a", name: "알파 상품", price: 12000 },
{ id: "b", name: "베타 상품", price: 4000 },
{ id: "c", name: "감마 상품", price: 19000 },
{ id: "d", name: "델타 상품", price: 8000 },
];
const HOLD_MS = 1000;
export default function ListReorder() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const sortBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const itemRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLLIElement | null)[]>;
const timerRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const SUPPORTS_VT = "startViewTransition" in document;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
let asc = true;
const items = itemRefs.current.filter((el): el is HTMLLIElement => !!el);
// 계산 겹: 카드마다 고유한 이름표 값을 지어 붙인다
items.forEach((el) => {
el.style.viewTransitionName = "lr-item-" + el.dataset.id;
});
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function render() {
const list = listRef.current;
if (!list) return;
const sorted = items.slice().sort((a, b) => {
const av = Number(a.dataset.v);
const bv = Number(b.dataset.v);
return asc ? av - bv : bv - av;
});
sorted.forEach((el) => list.appendChild(el));
if (sortBtnRef.current) sortBtnRef.current.textContent = asc ? "가격 오름차순" : "가격 내림차순";
}
function change(fn: () => void) {
if (SUPPORTS_VT) { document.startViewTransition(fn); }
else { fn(); }
}
function toggleSort() {
asc = !asc;
change(render);
}
function onClick() { human(); toggleSort(); }
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { human(); e.preventDefault(); toggleSort(); }
}
sortBtnRef.current?.addEventListener("click", onClick);
rootRef.current?.addEventListener("keydown", onKeydown);
function loop() {
if (!isDemo) return;
toggleSort();
timerRef.current = window.setTimeout(loop, HOLD_MS);
}
if (!reduce) {
timerRef.current = window.setTimeout(loop, HOLD_MS);
} else {
rootRef.current?.classList.remove(styles.isDemo);
}
return () => {
window.clearTimeout(timerRef.current);
sortBtnRef.current?.removeEventListener("click", onClick);
rootRef.current?.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.lr + " " + styles.isDemo} ref={rootRef}>
<button type="button" className={styles.sort} ref={sortBtnRef} aria-label="가격순 정렬 뒤집기">가격 오름차순</button>
<ul className={styles.list} ref={listRef}>
{ITEMS.map((it, i) => (
<li key={it.id} className={styles.item} data-id={it.id} ref={(el: HTMLLIElement | null) => { itemRefs.current[i] = el; }}>
<span className={styles.name}>{it.name}</span>
<span className={styles.price} data-v={it.price}>{it.price.toLocaleString("ko-KR")}</span>
</li>
))}
</ul>
</div>
</main>
);
}08 정렬 순서 값이 바뀔 때 미끄러지는 전환 — 측정 누적면적 26.685% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · view transitions api 9가지 — 값 하나로 화면 잇기
이 데모가 속한 글
view transitions api 9가지 — 값 하나로 화면 잇기화면이 그대로 이어진 것처럼 보이지만 실제로는 직전 화면을 스냅샷 값으로 남겨 다음 화면과 겹쳐 지나간다 — 이름표 값으로 요소를 짝짓고, 지원 여부 값으로 물러나고, 동작 줄임 값으로 지속시간을 끈다. 이 글은 v 이 글의 데모 전부 보기

