07Cross-fade nội dung tab
Đổi tab thì nội dung cũ biến mất, nội dung mới hiện ra hơi lệch lên trên.
Dùng choTab cài đặt, nội dung tab trong dashboard
200ms8px 오프셋깜빡임 없이

<main class="stage">
<div class="pages is-demo">
<div class="tabs">
<span class="tab tab--on">Chung</span>
<span class="tab">Bảo mật</span>
</div>
<section class="page page--a">
<p class="page__body">Đổi ngôn ngữ, giao diện, tần suất thông báo tại đây</p>
</section>
<section class="page page--b">
<p class="page__body">Đổi mật khẩu, xác thực 2 lớp tại đây</p>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
// ── 데모 무대 ───────────────────────────────
@include stage($stage-orange);
.pages { position: relative; width: min(300px, 88%); height: 150px; border-radius: $r-card; overflow: hidden; box-shadow: $shadow-raised; background: #fff; }
.tabs { position: absolute; top: 0; left: 0; right: 0; display: flex; gap: clamp(10px, 4vw, 16px); padding: clamp(10px, 4vw, 14px) clamp(12px, 5vw, 18px) 0; box-sizing: border-box; }
.tab { font-weight: 700; font-size: clamp(11px, 3.2vw, 13px); line-height: 1; font-family: inherit; color: $subject-ink; opacity: .4; padding-bottom: 8px; }
.tab--on { opacity: 1; color: $subject-ink; border-bottom: 2px solid $color; }
.page {
position: absolute; left: 0; right: 0; bottom: 0; top: 40px; box-sizing: border-box;
padding: clamp(12px, 5vw, 18px); display: flex; align-items: center;
}
.page__body { margin: 0; font-weight: 400; font-size: clamp(11px, 3.2vw, 13px); line-height: 1.5; font-family: inherit; color: $subject-ink; }
// 탭이 바뀌면 이전 콘텐츠는 사라지고 새 콘텐츠가 8px 위에서 겹쳐 내려온다.
.page--a { animation: tab-a $dur-loop $easing infinite; }
.page--b { animation: tab-b $dur-loop $easing infinite; opacity: 0; transform: translateY(8px); }
@keyframes tab-a {
0%, 5% { opacity: 1; transform: translateY(0); }
50% { opacity: 0; transform: translateY(-8px); }
95%, 100% { opacity: 1; transform: translateY(0); }
}
@keyframes tab-b {
0%, 5% { opacity: 0; transform: translateY(8px); }
50% { opacity: 1; transform: translateY(0); }
95%, 100% { opacity: 0; transform: translateY(8px); }
}
// ── 접근성: 움직임 줄이기 설정이면 최종 상태만 고정으로 ────────
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b { animation: none !important; }
.page--a { opacity: 1; transform: translateY(0); }
.page--b { opacity: 0; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pages {
position: relative;
width: min(300px, 88%);
height: 150px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff;
}
.tabs {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
gap: clamp(10px, 4vw, 16px);
padding: clamp(10px, 4vw, 14px) clamp(12px, 5vw, 18px) 0;
box-sizing: border-box;
}
.tab {
font-weight: 700;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
opacity: 0.4;
padding-bottom: 8px;
}
.tab--on {
opacity: 1;
color: #17141a;
border-bottom: 2px solid #fff7e6;
}
.page {
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 40px;
box-sizing: border-box;
padding: clamp(12px, 5vw, 18px);
display: flex;
align-items: center;
}
.page__body {
margin: 0;
font-weight: 400;
font-size: clamp(11px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
color: #17141a;
}
.page--a {
animation: tab-a 2s ease-out infinite;
}
.page--b {
animation: tab-b 2s ease-out infinite;
opacity: 0;
transform: translateY(8px);
}
@keyframes tab-a {
0%, 5% {
opacity: 1;
transform: translateY(0);
}
50% {
opacity: 0;
transform: translateY(-8px);
}
95%, 100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tab-b {
0%, 5% {
opacity: 0;
transform: translateY(8px);
}
50% {
opacity: 1;
transform: translateY(0);
}
95%, 100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (prefers-reduced-motion: reduce) {
.page--a, .page--b {
animation: none !important;
}
.page--a {
opacity: 1;
transform: translateY(0);
}
.page--b {
opacity: 0;
transform: translateY(0);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./TabCrossfadeTransition.module.scss";
export type TabCrossfadeTransitionProps = {
active: "a" | "b";
tabA: ReactNode;
tabB: ReactNode;
panelA: ReactNode;
panelB: ReactNode;
/** 전환 시간 (기본 200ms) */
duration?: number;
easing?: string;
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TabCrossfadeTransition({
active,
tabA,
tabB,
panelA,
panelB,
duration = 200,
easing = "ease-out",
color = "#fff7e6",
className = "",
}: TabCrossfadeTransitionProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.pages} ${className}`} style={vars} data-active={active}>
<div className={styles.tabs}>
<span className={styles.tab} data-on={active === "a"}>{tabA}</span>
<span className={styles.tab} data-on={active === "b"}>{tabB}</span>
</div>
<section className={styles.panelA}>{panelA}</section>
<section className={styles.panelB}>{panelB}</section>
</div>
);
}07 Cross-fade nội dung tab — Diện tích đo được 2.663% · khung hình chuyển động 22/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 hiệu ứng css page transition animation không JS
Bài viết chứa demo này
9 hiệu ứng css page transition animation không JSCss page transition animation là hiệu ứng chuyển trang dựng bằng transform, opacity và clip-path thay vì đợi router vẽ l Xem tất cả demo trong bài này


