03パネルフェード
タブを切り替えると前のパネルがopacityで消え、新しいパネルが重なって浮かび上がる。両パネルはgridの同じセルに重ねてあるのでレイアウトが揺れない。
使いどころ商品詳細の説明・レビュー・問い合わせタブ、FAQカテゴリタブ
opacitygrid 겹침220ms

<main class="stage">
<div class="fp is-demo">
<input type="radio" name="fp-tab" id="fp-1" class="fp__radio" checked>
<input type="radio" name="fp-tab" id="fp-2" class="fp__radio">
<input type="radio" name="fp-tab" id="fp-3" class="fp__radio">
<div class="fp__nav">
<label class="fp__tab" for="fp-1">紹介</label>
<label class="fp__tab" for="fp-2">レビュー</label>
<label class="fp__tab" for="fp-3">問い合わせ</label>
</div>
<div class="fp__panels">
<p class="fp__panel fp__panel--1">素材・サイズ・洗濯方法をまとめました。</p>
<p class="fp__panel fp__panel--2">評価と直近のレビュー342件をまとめました。</p>
<p class="fp__panel fp__panel--3">交換・返品はここからすぐ申請できます。</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-quick; // 패널이 겹쳐 페이드되는 시간
$easing: $ease-out; // 패널 페이드 이징 (실제 클릭)
$color: $subject-ink; // 활성 탭 글자색 (주황 면 위 5.50:1 — 크림은 3.11 로 미달)
@include stage($stage-orange);
.fp { width: min(300px, 86vw); }
.fp__radio { position: absolute; opacity: 0; pointer-events: none; }
.fp__nav {
position: relative;
display: flex;
gap: $sp-4;
border-bottom: 1px solid rgba(255, 247, 230, .25);
padding-bottom: $sp-2;
}
.fp__tab {
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: rgba($subject-ink, .85); // 비활성 탭 — 주황 면 위 4.62:1 (.72 는 3.76 으로 미달)
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
#fp-1:checked ~ .fp__nav label[for="fp-1"],
#fp-2:checked ~ .fp__nav label[for="fp-2"],
#fp-3:checked ~ .fp__nav label[for="fp-3"] { color: $color; }
// 세 패널을 같은 grid 칸에 포개 두면 자리가 안 흔들린다
.fp__panels { position: relative; display: grid; margin-top: $sp-4; min-height: 40px; }
.fp__panel {
grid-area: 1 / 1;
margin: 0;
color: $subject-ink; // 패널 본문 — 5.50:1
font-weight: 400;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
#fp-1:checked ~ .fp__panels .fp__panel--1,
#fp-2:checked ~ .fp__panels .fp__panel--2,
#fp-3:checked ~ .fp__panels .fp__panel--3 { opacity: 1; pointer-events: auto; }
// ── 데모 루프: 클릭 전까지 세 패널을 스스로 오간다 ──────────────
// 한 겹(관성) — 밑줄이 없는 이 항목은 패널 전환에 관성을 얹는다: 등장 패널은 8px 아래에서
// 올라와 -2px 살짝 지나쳤다가(오버슛) 0 에 정착, 사라지는 패널은 반대로 2px 위로 흐르며 fade.
// 정적(:checked) 전환 경로는 opacity 만인 원본 그대로 — 관성은 -inertia 루프 키프레임 안에서만.
.fp.is-demo .fp__panel--1 { animation: fp-loop-1-inertia $dur-loop $easing infinite; }
.fp.is-demo .fp__panel--2 { animation: fp-loop-2-inertia $dur-loop $easing infinite; }
.fp.is-demo .fp__panel--3 { animation: fp-loop-3-inertia $dur-loop $easing infinite; }
@keyframes fp-loop-1-inertia {
0%, 15% { opacity: 1; transform: translateY(0); }
22% { transform: translateY(-1px); }
30%, 100% { opacity: 0; transform: translateY(-2px); }
}
@keyframes fp-loop-2-inertia {
0%, 30% { opacity: 0; transform: translateY(8px); }
42% { transform: translateY(-2px); }
45%, 63% { opacity: 1; transform: translateY(0); }
70% { transform: translateY(-1px); }
78%, 100% { opacity: 0; transform: translateY(-2px); }
}
@keyframes fp-loop-3-inertia {
0%, 63% { opacity: 0; transform: translateY(8px); }
75% { transform: translateY(-2px); }
78%, 96% { opacity: 1; transform: translateY(0); }
98% { transform: translateY(-1px); }
100% { opacity: 0; transform: translateY(-2px); }
}
@media (prefers-reduced-motion: reduce) {
.fp__panel { transition: none; }
.fp.is-demo .fp__panel--1,
.fp.is-demo .fp__panel--2,
.fp.is-demo .fp__panel--3 { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fp {
width: min(300px, 86vw);
}
.fp__radio {
position: absolute;
opacity: 0;
pointer-events: none;
}
.fp__nav {
position: relative;
display: flex;
gap: 16px;
border-bottom: 1px solid rgba(255, 247, 230, 0.25);
padding-bottom: 8px;
}
.fp__tab {
font-weight: 600;
font-size: clamp(12px, 3.4vw, 14px);
line-height: 1;
font-family: inherit;
color: rgba(23, 20, 26, 0.85);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
#fp-1:checked ~ .fp__nav label[for=fp-1],
#fp-2:checked ~ .fp__nav label[for=fp-2],
#fp-3:checked ~ .fp__nav label[for=fp-3] {
color: #17141a;
}
.fp__panels {
position: relative;
display: grid;
margin-top: 16px;
min-height: 40px;
}
.fp__panel {
grid-area: 1/1;
margin: 0;
color: #17141a;
font-weight: 400;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.5;
font-family: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 220ms ease-out;
}
#fp-1:checked ~ .fp__panels .fp__panel--1,
#fp-2:checked ~ .fp__panels .fp__panel--2,
#fp-3:checked ~ .fp__panels .fp__panel--3 {
opacity: 1;
pointer-events: auto;
}
.fp.is-demo .fp__panel--1 {
animation: fp-loop-1-inertia 2s ease-out infinite;
}
.fp.is-demo .fp__panel--2 {
animation: fp-loop-2-inertia 2s ease-out infinite;
}
.fp.is-demo .fp__panel--3 {
animation: fp-loop-3-inertia 2s ease-out infinite;
}
@keyframes fp-loop-1-inertia {
0%, 15% {
opacity: 1;
transform: translateY(0);
}
22% {
transform: translateY(-1px);
}
30%, 100% {
opacity: 0;
transform: translateY(-2px);
}
}
@keyframes fp-loop-2-inertia {
0%, 30% {
opacity: 0;
transform: translateY(8px);
}
42% {
transform: translateY(-2px);
}
45%, 63% {
opacity: 1;
transform: translateY(0);
}
70% {
transform: translateY(-1px);
}
78%, 100% {
opacity: 0;
transform: translateY(-2px);
}
}
@keyframes fp-loop-3-inertia {
0%, 63% {
opacity: 0;
transform: translateY(8px);
}
75% {
transform: translateY(-2px);
}
78%, 96% {
opacity: 1;
transform: translateY(0);
}
98% {
transform: translateY(-1px);
}
100% {
opacity: 0;
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.fp__panel {
transition: none;
}
.fp.is-demo .fp__panel--1,
.fp.is-demo .fp__panel--2,
.fp.is-demo .fp__panel--3 {
animation: none !important;
}
}// 페이드는 :checked 만으로 이미 동작한다. 실제 클릭이 오면 데모 루프(.is-demo)만 뗀다.
document.querySelectorAll('.fp__radio').forEach(function (r) {
r.addEventListener('pointerdown', function () { document.querySelector('.fp').classList.remove('is-demo'); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FadePanel.module.scss";
const TABS = [
{ label: "소개", panel: "소재·사이즈·세탁법을 정리했습니다." },
{ label: "리뷰", panel: "별점과 최근 구매평 342건을 모았습니다." },
{ label: "문의", panel: "교환·환불은 여기서 바로 신청하세요." },
];
export type FadePanelProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FadePanel({ duration = 220, easing = "ease-out", color = "#17141a" }: FadePanelProps) { // 주황 무대 위 활성 탭 글자 — 크림은 3.11 로 미달
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.fp} style={vars}>
<div className={styles.nav} role="tablist">
{TABS.map((t, i) => (
<button key={t.label} type="button" role="tab" aria-selected={i === active}
className={styles.tab} onClick={() => setActive(i)}>
{t.label}
</button>
))}
</div>
<div className={styles.panels}>
{TABS.map((t, i) => (
<p key={t.label} className={styles.panel} style={{ opacity: i === active ? 1 : 0 }} aria-hidden={i !== active}>
{t.panel}
</p>
))}
</div>
</div>
);
}03 パネルフェード — 計測 累積面積 2.14% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · CSS タブ 切り替え アニメーション9選 — JS最小・CSSだけ
このデモが属する記事
CSS タブ 切り替え アニメーション9選 — JS最小・CSSだけcss タブ 切り替え アニメーションは、ラジオボタンの:checked状態やrole="tablist"ボタンだけでインジケーターが滑り、パネルの中身が入れ替わる仕組みです。9個のうち7個はCSSだけで動きますが、矢印キー操作が要る2個だ この記事のデモをすべて見る


