Files
invyone/frontend/lib/themeTransition.ts
T
2026-04-08 04:55:12 +09:00

100 lines
4.8 KiB
TypeScript

/**
* 테마 전환 애니메이션 — View Transitions API + soft circular reveal.
*
* 동작: 클릭한 (x, y) 위치에서 새 테마가 부드러운 가장자리(radial-gradient feather)를 가진
* 원형 mask 로 퍼져나간다. clip-path 와 달리 가장자리가 sharp 하지 않고 색이 자연스럽게
* 번지는 느낌을 준다.
*
* 실제 애니메이션은 globals.css 의 ::view-transition-new(root) 의 mask-image + @property
* 등록된 --reveal-radius 키프레임이 담당한다. 이 함수는:
* 1) CSS 커스텀 변수 (--reveal-x/-y/-max) 를 root 에 세팅
* 2) startViewTransition() 으로 DOM 캡처 → 새 root 의 dark/light 클래스 swap
*
* View Transitions API 미지원 브라우저(주로 Firefox)는 즉시 swap 으로 fallback.
*/
type ApplyTheme = (theme: "light" | "dark") => void;
type DocumentWithVT = Document & {
startViewTransition?: (cb: () => void | Promise<void>) => {
ready: Promise<void>;
finished: Promise<void>;
};
};
export function animatedThemeChange(
next: "light" | "dark",
applyTheme: ApplyTheme,
origin?: { x: number; y: number },
): void {
const doc = document as DocumentWithVT;
// 클릭 위치(없으면 화면 중앙)
const x = origin?.x ?? window.innerWidth / 2;
const y = origin?.y ?? window.innerHeight / 2;
// 클릭 지점에서 화면 가장 먼 모서리까지 거리
const cornerDistance = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
// ★ 중요: 그라데이션의 안쪽 25% 만 solid (alpha 1.0) 이고 나머지 75% 는 페이드.
// 애니메이션 끝났을 때 화면 모서리가 페이드 구간이 아니라 solid 구간에 들어가야 새 테마가
// 완전히 화면을 덮은 것처럼 보임. 모서리가 그라데이션의 0.22 (= 1/4.5) 위치에 떨어지도록
// endRadius 를 모서리 거리의 4.5 배로 설정 → 안쪽 25% 영역 안에 안전하게 들어감.
const endRadius = cornerDistance * 4.5;
// CSS 가 사용할 변수 세팅 (root 에 두면 ::view-transition-new(root) 가 상속)
const root = document.documentElement;
root.style.setProperty("--reveal-x", `${x}px`);
root.style.setProperty("--reveal-y", `${y}px`);
root.style.setProperty("--reveal-max", `${endRadius}px`);
// Perceived speed 보정: 다크로 갈 땐 검정 컨트라스트가 강해서 같은 duration 도 빠르게 느껴짐.
// dark 방향만 약 25% 더 길게 잡아 두 방향이 비슷한 속도로 인지되도록 함.
root.style.setProperty("--vt-duration", next === "dark" ? "2200ms" : "1700ms");
// View Transitions API 미지원 → 즉시 적용
if (!doc.startViewTransition) {
applyTheme(next);
return;
}
// ★ 핵심: VT 캡처 전에 모든 CSS transition 을 일시 비활성화.
// v5-layout.css 등에서 `transition: all .3s` 같은 규칙이 여러 곳에 깔려있어서, 다크 클래스를
// 토글하면 background-color / color 가 자체 transition 으로 천천히 보간된다. 그 결과 VT 가
// 끝난 뒤에도 진짜 DOM 에서 색이 계속 변하는 것처럼 보임("끝났는데 더 어두워지는 느낌").
// disable style 을 콜백 직전에 주입 → VT 가 새 스냅샷을 캡처할 땐 이미 transition 무효화 →
// transition.ready 후 제거하면 평소 hover 등은 정상 동작.
const disableStyle = document.createElement("style");
disableStyle.setAttribute("data-vt-theme-disable", "");
// ★ transition 만 죽이고 animation 은 그대로 둘 것!
// animation-duration:0s 를 넣으면 VT 의 vt-soft-reveal 애니메이션이 영향을 받아
// 퍼지는 속도가 빨라진다. 우리가 막고 싶은 건 색깔 보간(transition)만이지 animation 이 아님.
disableStyle.appendChild(
document.createTextNode(
"*,*::before,*::after{transition:none!important;}",
),
);
const transition = doc.startViewTransition(() => {
// disable 주입 → 클래스 토글이 transition 없이 즉시 반영되도록
document.head.appendChild(disableStyle);
// VT 의 캡처 시점에 DOM 이 새 테마 상태가 되도록 즉시 클래스 토글.
// next-themes 도 함께 호출해서 localStorage / context 동기화.
document.documentElement.classList.toggle("dark", next === "dark");
applyTheme(next);
});
// 새 스냅샷 캡처 완료 후 disable 제거 — 이 시점엔 진짜 DOM 은 VT pseudo 뒤에 가려져 있어
// 사용자 눈에는 안 보이지만, transition 은 평소대로 복구되어 hover 등에 영향 없음
transition.ready
.then(() => {
// 강제 reflow 후 제거 (브라우저가 disable 상태를 확실히 적용한 뒤 풀도록)
void document.body.offsetHeight;
disableStyle.remove();
})
.catch(() => {
// VT 실패 시에도 disable 은 제거해야 함
disableStyle.remove();
});
}