09斜めシーン交代
画面を斜めに分ける二枚の板が同じ方向へ、わずかに時差をつけて滑って通り過ぎ、後ろに隠れていた次のシーンを一度に現す。
使いどころランディングのセクション交代、ストーリーページの遷移
skewX translateX장면 이중 겹침transition-delay: 100ms

<main class="stage">
<section class="scene is-demo" aria-label="斜めの2枚の板が通り過ぎてシーンが変わる">
<input class="scene__check" type="checkbox" id="scene-swap">
<div class="scene__frame">
<div class="scene__inner scene__inner--a"><p class="scene__name">春コレクション</p></div>
<div class="scene__inner scene__inner--b"><p class="scene__name">夏コレクション</p></div>
<div class="scene__wipes" aria-hidden="true">
<span class="scene__wipe scene__wipe--1"></span>
<span class="scene__wipe scene__wipe--2"></span>
</div>
</div>
<label class="scene__btn" for="scene-swap">シーンを変える</label>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 1s; // 판이 프레임을 통과하는 시간
$easing: $ease-spring;
$color: $subject-blue; // A 장면 색
@include stage($stage-ink);
.scene {
display: grid;
justify-items: center;
gap: $sp-3;
}
.scene__check { position: absolute; opacity: 0; pointer-events: none; }
.scene__frame {
position: relative;
width: min(320px, 90vw);
height: clamp(96px, 50vh, 172px);
border-radius: $r-card;
overflow: hidden;
}
.scene__inner {
position: absolute; inset: 0;
display: grid;
place-items: center;
transition: opacity $dur-quick linear calc(#{$duration} / 2); // 판이 덮는 중간에 교체
}
.scene__inner--a { background: linear-gradient(140deg, $color 0%, $subject-cream 100%); opacity: 1; }
.scene__inner--b { background: linear-gradient(140deg, $stage-orange 0%, $stage-yellow 100%); opacity: 0; }
.scene__name {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.2vw, 24px);
font-family: inherit;
color: $stage-ink;
}
// ── 대각선 판 2개: skew 로 비스듬히 눕히고 좌우로 통과시킨다 (clip-path 아님)
.scene__wipes { position: absolute; inset: 0; }
.scene__wipe {
position: absolute;
top: -20%; bottom: -20%; left: -45%;
width: 190%;
background: $stage-paper;
transform: skewX(-14deg) translateX(-115%);
}
.scene__wipe--2 { background: rgba(255, 210, 63, .92); }
// 버튼: 판이 프레임을 쓸고 지나가며 장면이 바뀐다
.scene__btn {
padding: $sp-2 $sp-5;
border-radius: $r-control;
border: 2px solid rgba(255, 255, 255, .4);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: $stage-paper;
cursor: pointer;
transition: transform $dur-quick $easing, border-color $dur-quick $easing;
}
.scene__btn:active { transform: scale(.94); }
// 체크: 판이 왼→오른쪽으로 통과 (transition 이라 해제하면 역방향 통과가 된다)
.scene__wipe { transition: transform $duration $easing; }
.scene__wipe--2 { transition-delay: 100ms; }
#scene-swap:checked ~ .scene__frame .scene__wipe { transform: skewX(-14deg) translateX(115%); }
#scene-swap:checked ~ .scene__frame .scene__inner--a { opacity: 0; }
#scene-swap:checked ~ .scene__frame .scene__inner--b { opacity: 1; }
// 렌더·갤러리용 자동 루프: A → 판 통과 → B → 판 역통과 → A
.scene.is-demo .scene__wipe--1 { animation: wipe-loop-1 $dur-loop $easing infinite; }
.scene.is-demo .scene__wipe--2 { animation: wipe-loop-2 $dur-loop $easing infinite; }
.scene.is-demo .scene__inner--a { animation: inner-a $dur-loop $easing infinite; }
.scene.is-demo .scene__inner--b { animation: inner-b $dur-loop $easing infinite; }
@keyframes wipe-loop-1 {
0%, 8% { transform: skewX(-14deg) translateX(-115%); } // 순방향 통과
34% { transform: skewX(-14deg) translateX(115%); }
52%, 58% { transform: skewX(-14deg) translateX(115%); } // 역방향 통과
84%, 100% { transform: skewX(-14deg) translateX(-115%); }
}
@keyframes wipe-loop-2 {
0%, 14% { transform: skewX(-14deg) translateX(-115%); }
40%, 60% { transform: skewX(-14deg) translateX(115%); }
86%, 100% { transform: skewX(-14deg) translateX(-115%); }
}
@keyframes inner-a {
0%, 20% { opacity: 1; }
26%, 70% { opacity: 0; }
76%, 100% { opacity: 1; }
}
@keyframes inner-b {
0%, 20% { opacity: 0; }
26%, 70% { opacity: 1; }
76%, 100% { opacity: 0; }
}
@media (max-width: 360px) { // 320×200 아이프레임 대응
.scene__frame { height: 96px; }
.scene__name { font-size: 15px; }
.scene__btn { padding: 4px 16px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.scene__inner, .scene__btn { transition: none; }
.scene.is-demo .scene__wipe,
.scene.is-demo .scene__inner { animation: none; } // 판 없이 장면만 즉시 바뀐다
.scene.is-demo .scene__wipe { transform: skewX(-14deg) translateX(-115%); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.scene {
display: grid;
justify-items: center;
gap: 12px;
}
.scene__check {
position: absolute;
opacity: 0;
pointer-events: none;
}
.scene__frame {
position: relative;
width: min(320px, 90vw);
height: clamp(96px, 50vh, 172px);
border-radius: 16px;
overflow: hidden;
}
.scene__inner {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transition: opacity 220ms linear calc(1s / 2);
}
.scene__inner--a {
background: linear-gradient(140deg, #2f6df6 0%, #fff7e6 100%);
opacity: 1;
}
.scene__inner--b {
background: linear-gradient(140deg, #ff4d1f 0%, #ffd23f 100%);
opacity: 0;
}
.scene__name {
margin: 0;
font-weight: 800;
font-size: clamp(18px, 5.2vw, 24px);
font-family: inherit;
color: #17141a;
}
.scene__wipes {
position: absolute;
inset: 0;
}
.scene__wipe {
position: absolute;
top: -20%;
bottom: -20%;
left: -45%;
width: 190%;
background: #ffffff;
transform: skewX(-14deg) translateX(-115%);
}
.scene__wipe--2 {
background: rgba(255, 210, 63, 0.92);
}
.scene__btn {
padding: 8px 20px;
border-radius: 12px;
border: 2px solid rgba(255, 255, 255, 0.4);
font-weight: 700;
font-size: 13px;
font-family: inherit;
color: #ffffff;
cursor: pointer;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.scene__btn:active {
transform: scale(0.94);
}
.scene__wipe {
transition: transform 1s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.scene__wipe--2 {
transition-delay: 100ms;
}
#scene-swap:checked ~ .scene__frame .scene__wipe {
transform: skewX(-14deg) translateX(115%);
}
#scene-swap:checked ~ .scene__frame .scene__inner--a {
opacity: 0;
}
#scene-swap:checked ~ .scene__frame .scene__inner--b {
opacity: 1;
}
.scene.is-demo .scene__wipe--1 {
animation: wipe-loop-1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__wipe--2 {
animation: wipe-loop-2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__inner--a {
animation: inner-a 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.scene.is-demo .scene__inner--b {
animation: inner-b 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wipe-loop-1 {
0%, 8% {
transform: skewX(-14deg) translateX(-115%);
}
34% {
transform: skewX(-14deg) translateX(115%);
}
52%, 58% {
transform: skewX(-14deg) translateX(115%);
}
84%, 100% {
transform: skewX(-14deg) translateX(-115%);
}
}
@keyframes wipe-loop-2 {
0%, 14% {
transform: skewX(-14deg) translateX(-115%);
}
40%, 60% {
transform: skewX(-14deg) translateX(115%);
}
86%, 100% {
transform: skewX(-14deg) translateX(-115%);
}
}
@keyframes inner-a {
0%, 20% {
opacity: 1;
}
26%, 70% {
opacity: 0;
}
76%, 100% {
opacity: 1;
}
}
@keyframes inner-b {
0%, 20% {
opacity: 0;
}
26%, 70% {
opacity: 1;
}
76%, 100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.scene__frame {
height: 96px;
}
.scene__name {
font-size: 15px;
}
.scene__btn {
padding: 4px 16px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.scene__inner, .scene__btn {
transition: none;
}
.scene.is-demo .scene__wipe,
.scene.is-demo .scene__inner {
animation: none;
}
.scene.is-demo .scene__wipe {
transform: skewX(-14deg) translateX(-115%);
}
}import type { CSSProperties } from "react";
import { useState } from "react";
import styles from "./SplitDiagonalSwap.module.scss";
export type SplitDiagonalSwapProps = {
sceneA: string;
sceneB: string;
swapLabel: string;
/** 판이 프레임을 통과하는 시간 (기본 1000ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SplitDiagonalSwap({
sceneA,
sceneB,
swapLabel,
duration = 1000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SplitDiagonalSwapProps) {
const [atB, setAtB] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<section className={styles.scene} style={vars} aria-label={`${sceneA} / ${sceneB}`}>
<div className={styles.frame}>
<div className={`${styles.inner} ${styles.innerA} ${atB ? styles.innerHide : ""}`}>
<p className={styles.name}>{sceneA}</p>
</div>
<div className={`${styles.inner} ${styles.innerB} ${atB ? styles.innerShow : ""}`}>
<p className={styles.name}>{sceneB}</p>
</div>
<div className={styles.wipes} aria-hidden="true">
<span className={`${styles.wipe} ${styles.wipe1} ${atB ? styles.wipeGo : ""}`} />
<span className={`${styles.wipe} ${styles.wipe2} ${atB ? styles.wipeGo : ""}`} />
</div>
</div>
<button type="button" className={styles.btn} onClick={() => setAtB((v) => !v)}>{swapLabel}</button>
</section>
);
}09 斜めシーン交代 — 計測 累積面積 33.21% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · CSS 画面切り替え アニメーション9選
このデモが属する記事
CSS 画面切り替え アニメーション9選CSS 画面切り替え アニメーションとは、ページを読み込み直さずに、画像が別の画像に変わったり、注文が確定したり、フォームが次の段階へ進んだりするとき、その変化を滑らかに見せる短い動きのことです。画面が切り替わる瞬間ごとにそのまま貼れる9種 この記事のデモをすべて見る

