06Slide Transition That Flips by Reading a Direction Value
A direction value recording which button was just pressed is checked so the same code slides the incoming screen in from one side going forward and from the opposite side going back.
Best forTab-order navigation, wizard-step navigation
--dir::view-transition-old(ds-step)$slide-travel-pct

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__dots" id="dsDots">
<span class="ds__dot is-active" data-i="0"></span>
<span class="ds__dot" data-i="1"></span>
<span class="ds__dot" data-i="2"></span>
<span class="ds__dot" data-i="3"></span>
</div>
<div class="ds__panel" id="dsPanel">
<p class="ds__num" id="dsNum">1</p>
<p class="ds__t" id="dsT">Address</p>
</div>
<div class="ds__nav">
<button type="button" class="ds__navBtn" id="dsPrev" aria-label="Previous step">Previous</button>
<button type="button" class="ds__navBtn" id="dsNext" aria-label="Next step">Next</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-cream;
$slide-travel-pct: 100%;
@include stage($stage-yellow);
:root { --dir: 1; }
.ds { width: min(100%, 280px); display: flex; flex-direction: column; align-items: center; gap: $sp-4; }
.ds__dots { display: flex; gap: $sp-2; }
.ds__dot { width: 8px; height: 8px; border-radius: 50%; background: rgba($subject-ink, .28); }
.ds__dot.is-active { background: $subject-ink; }
.ds__panel {
view-transition-name: ds-step;
position: relative; width: 100%; box-sizing: border-box; overflow: hidden;
background: $subject-ink; border-radius: $r-card; padding: $sp-5 $sp-4;
display: flex; flex-direction: column; align-items: center; gap: $sp-2; text-align: center;
}
.ds__num { margin: 0; font: 700 30px/1 "Space Mono", monospace; color: $color; }
.ds__t { margin: 0; font: 600 13px/1.3 -apple-system, "Segoe UI", sans-serif; color: rgba($color, .88); }
.ds__nav { display: flex; gap: $sp-3; }
.ds__navBtn {
border: none; border-radius: $r-pill; cursor: pointer; background: rgba($subject-ink, .1);
color: $subject-ink; font: 700 11px/1 -apple-system, sans-serif; padding: $sp-1 $sp-4;
}
.ds__navBtn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 실사용 원리: --dir 값(+1/-1)을 전환 시작 직전에 문서 루트에 심어 두면 view-transition 의사요소
// 트리도 그 커스텀 프로퍼티를 물려받는다 — 그래서 같은 키프레임 한 쌍으로 앞으로 갈 땐 오른쪽에서
// 뒤로 갈 땐 왼쪽에서 들어오는 반대 방향 슬라이드를 만들 수 있다.
::view-transition-old(ds-step) {
animation: ds-out $duration $easing both;
}
::view-transition-new(ds-step) {
animation: ds-in $duration $easing both;
}
@keyframes ds-out {
to { transform: translateX(calc(var(--dir) * -#{$slide-travel-pct})); opacity: 0; }
}
@keyframes ds-in {
from { transform: translateX(calc(var(--dir) * #{$slide-travel-pct})); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (max-width: 340px) {
.ds__panel { padding: $sp-4 $sp-3; }
.ds__num { font-size: 24px; }
.ds__t { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
:root {
--dir: 1;
}
.ds {
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.ds__dots {
display: flex;
gap: 8px;
}
.ds__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.28);
}
.ds__dot.is-active {
background: #17141a;
}
.ds__panel {
view-transition-name: ds-step;
position: relative;
width: 100%;
box-sizing: border-box;
overflow: hidden;
background: #17141a;
border-radius: 16px;
padding: 20px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.ds__num {
margin: 0;
font: 700 30px/1 "Space Mono", monospace;
color: #fff7e6;
}
.ds__t {
margin: 0;
font: 600 13px/1.3 -apple-system, "Segoe UI", sans-serif;
color: rgba(255, 247, 230, 0.88);
}
.ds__nav {
display: flex;
gap: 12px;
}
.ds__navBtn {
border: none;
border-radius: 999px;
cursor: pointer;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font: 700 11px/1 -apple-system, sans-serif;
padding: 4px 16px;
}
.ds__navBtn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
::view-transition-old(ds-step) {
animation: ds-out 300ms ease-out both;
}
::view-transition-new(ds-step) {
animation: ds-in 300ms ease-out both;
}
@keyframes ds-out {
to {
transform: translateX(calc(var(--dir) * -100%));
opacity: 0;
}
}
@keyframes ds-in {
from {
transform: translateX(calc(var(--dir) * 100%));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (max-width: 340px) {
.ds__panel {
padding: 16px 12px;
}
.ds__num {
font-size: 24px;
}
.ds__t {
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('ds');
var panel = document.getElementById('dsPanel');
var numEl = document.getElementById('dsNum');
var tEl = document.getElementById('dsT');
var dots = Array.prototype.slice.call(document.querySelectorAll('.ds__dot'));
var prevBtn = document.getElementById('dsPrev');
var nextBtn = document.getElementById('dsNext');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var STEPS = ['Address', 'Confirm payment', 'Delivery method', 'Done'];
var idx = 0;
var isDemo = true;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function render(newIdx) {
numEl.textContent = String(newIdx + 1);
tEl.textContent = STEPS[newIdx];
dots.forEach(function (d, i) { d.classList.toggle('is-active', i === newIdx); });
idx = newIdx;
}
function change(fn) {
if (SUPPORTS_VT) { document.startViewTransition(fn); }
else { fn(); }
}
function goto(newIdx) {
if (newIdx === idx || newIdx < 0 || newIdx > 3) return;
// 계산 겹: Next 인덱스 - 현재 인덱스의 부호만 취해 이동 방향 값을 얻는다 (+1 앞으로 · -1 Back)
var dir = Math.sign(newIdx - idx) || 1;
document.documentElement.style.setProperty('--dir', String(dir));
change(function () { render(newIdx); });
}
nextBtn.addEventListener('click', function () { human(); goto(idx + 1 > 3 ? 0 : idx + 1); });
prevBtn.addEventListener('click', function () { human(); goto(idx - 1 < 0 ? 3 : idx - 1); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { human(); goto(idx + 1 > 3 ? 0 : idx + 1); }
else if (e.key === 'ArrowLeft') { human(); goto(idx - 1 < 0 ? 3 : idx - 1); }
});
var seq = [1, 2, 3, 2, 1, 0];
var si = 0;
var HOLD_MS = 900;
function loop() {
if (!isDemo) return;
goto(seq[si % seq.length]);
si++;
window.setTimeout(loop, HOLD_MS);
}
if (!reduce) {
window.setTimeout(loop, HOLD_MS);
} else {
root.classList.remove('is-demo');
}
})();// DirectionAwareSlide — vanilla 판(run/401/demo/06-view-transition-direction-aware-slide/index.html) 의 React 포트.
// 계산 겹: 다음 인덱스 - 현재 인덱스의 부호만 취해 이동 방향 값을 얻는다 (+1 앞으로 · -1 뒤로).
import { useEffect, useRef } from "react";
import styles from "./DirectionAwareSlide.module.scss";
type Box<T> = { current: T };
const STEPS = ["주소 입력", "결제 값 확인", "수령 방법", "완료"];
const SEQ = [1, 2, 3, 2, 1, 0];
const HOLD_MS = 900;
export default function DirectionAwareSlide() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const numRef = useRef(null) as unknown as Box<HTMLElement>;
const tRef = useRef(null) as unknown as Box<HTMLElement>;
const dotRefs = useRef([null, null, null, null]) as unknown as Box<(HTMLSpanElement | null)[]>;
const prevBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const nextBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
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 idx = 0;
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function render(newIdx: number) {
if (numRef.current) numRef.current.textContent = String(newIdx + 1);
if (tRef.current) tRef.current.textContent = STEPS[newIdx];
dotRefs.current.forEach((d, i) => d?.classList.toggle(styles.isActive, i === newIdx));
idx = newIdx;
}
function change(fn: () => void) {
if (SUPPORTS_VT) { document.startViewTransition(fn); }
else { fn(); }
}
function goto(newIdx: number) {
if (newIdx === idx || newIdx < 0 || newIdx > 3) return;
const dir = Math.sign(newIdx - idx) || 1;
document.documentElement.style.setProperty("--dir", String(dir));
change(() => render(newIdx));
}
function onNext() { human(); goto(idx + 1 > 3 ? 0 : idx + 1); }
function onPrev() { human(); goto(idx - 1 < 0 ? 3 : idx - 1); }
function onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowRight") { human(); goto(idx + 1 > 3 ? 0 : idx + 1); }
else if (e.key === "ArrowLeft") { human(); goto(idx - 1 < 0 ? 3 : idx - 1); }
}
nextBtnRef.current?.addEventListener("click", onNext);
prevBtnRef.current?.addEventListener("click", onPrev);
rootRef.current?.addEventListener("keydown", onKeydown);
let si = 0;
function loop() {
if (!isDemo) return;
goto(SEQ[si % SEQ.length]);
si++;
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);
nextBtnRef.current?.removeEventListener("click", onNext);
prevBtnRef.current?.removeEventListener("click", onPrev);
rootRef.current?.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + " " + styles.isDemo} ref={rootRef}>
<div className={styles.dots}>
<span className={styles.dot + " " + styles.isActive} ref={(el: HTMLSpanElement | null) => { dotRefs.current[0] = el; }} />
<span className={styles.dot} ref={(el: HTMLSpanElement | null) => { dotRefs.current[1] = el; }} />
<span className={styles.dot} ref={(el: HTMLSpanElement | null) => { dotRefs.current[2] = el; }} />
<span className={styles.dot} ref={(el: HTMLSpanElement | null) => { dotRefs.current[3] = el; }} />
</div>
<div className={styles.panel}>
<p className={styles.num} ref={numRef}>1</p>
<p className={styles.t} ref={tRef}>주소 입력</p>
</div>
<div className={styles.nav}>
<button type="button" className={styles.navBtn} ref={prevBtnRef} aria-label="이전 단계">이전</button>
<button type="button" className={styles.navBtn} ref={nextBtnRef} aria-label="다음 단계">다음</button>
</div>
</div>
</main>
);
}06 Slide Transition That Flips by Reading a Direction Value — Measured area 31.792% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · View Transitions API: 9 Patterns by One Value
The post this demo lives in
View Transitions API: 9 Patterns by One ValueView transitions api: The page seems to carry over, but the browser keeps the outgoing view as a snapshot value layered See all demos in this post


