07スプリットスクリーン
左右の半分が translateX で反対方向へ抜け、中央の縦線が開き、その隙間が画面幅まで広がると本文が収まります。
使いどころ製品詳細へ入る導入演出
translateXwill-change$dur-enter

<main class="stage">
<section class="scr is-demo" id="scr" aria-label="サイトの最初の画面">
<div class="scr__content" id="content">
<p class="scr__kicker">エディション12</p>
<h1 class="scr__title">ストーリー工房</h1>
<p class="scr__sub">毎週金曜日、短い物語を一つ</p>
<button class="scr__cta" type="button">購読する</button>
</div>
<div class="scr__panel scr__panel--l" aria-hidden="true"><span class="scr__word">ストーリー</span></div>
<div class="scr__panel scr__panel--r" aria-hidden="true"><span class="scr__word">工房</span></div>
<span class="scr__seam" aria-hidden="true"></span>
<button class="scr__skip" id="skip" type="button">スキップ</button>
<p class="scr__sr" id="status" aria-live="polite">イントロ再生中</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스플릿 스크린 오픈 — 화면을 덮은 좌우 반쪽이 translateX 로 서로 반대 방향으로 빠지며
// 가운데 세로선이 벌어지고, 틈이 화면 폭만큼 벌어지면 아래 깔린 첫 화면이 자리를 잡는다.
// 기본(작성) 상태 = 완료 상태(반쪽은 밖으로 빠지고 콘텐츠는 안착). 애니메이션 0% 키프레임이
// 시작 장면(닫힘)을 덮으므로 건너뛰기(.is-done)와 reduced-motion 은 animation-name: none 만으로 완료 상태가 된다.
$duration: $dur-enter; // 칩: $dur-enter — 실제 인트로 한 번의 길이
$easing: $ease-spring;
$color: $subject-blue-deep; // 왼쪽 반쪽 면색 (변형 color 가 바꾼다)
@include stage($stage-orange);
.scr { position: relative; width: 100%; height: 100%; overflow: hidden; }
// ── 이음선 아래 깔리는 사이트 첫 화면. 불투명하게 깔리고 반쪽이 걷히면 틈으로 드러난다 —
// 투명도 애니메이션이 없어 유령 프레임이 생기지 않고, 어떤 순간을 찍어도 화면이 비지 않는다
.scr__content {
position: absolute; inset: 0; z-index: 1;
display: grid; place-content: center; justify-items: center; gap: $sp-2;
text-align: center;
animation-name: scrContentOnce; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: 1; animation-fill-mode: backwards;
}
.scr__kicker {
margin: 0; color: $subject-ink; // 주황 무대 위 5.50:1 (run/016/_probe/a3_대비.py)
font-weight: 700; font-size: clamp(11px, 2.6vw, 13px); line-height: 1.4; letter-spacing: .14em;
}
.scr__title {
margin: 0; color: $subject-ink; // 5.50:1
font-weight: 800; font-size: clamp(22px, 6vw, 34px); line-height: 1.2;
}
.scr__sub {
margin: 0; color: $subject-ink; // 주황 위 흐린 글자는 알파를 빼도 4.5 미만이라 단색 잉크
font-weight: 500; font-size: clamp(12px, 3vw, 14px); line-height: 1.5;
}
.scr__cta {
margin: 0; border: 0; border-radius: $r-pill; padding: $sp-2 $sp-4;
background: $subject-blue-deep; color: $subject-cream; // 5.98:1
font-family: inherit; font-weight: 700; font-size: clamp(12px, 3vw, 14px); line-height: 1.4;
cursor: pointer;
}
.scr__cta:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// ── 화면을 덮은 좌우 반쪽. 기본 상태 = 다 빠진(완료) 상태
.scr__panel {
position: absolute; top: 0; bottom: 0; width: 50.5%; z-index: 2;
display: flex; align-items: center;
will-change: transform; // 칩: will-change — 이동 전에 합성 계층으로 올린다
}
.scr__panel--l {
left: 0; justify-content: flex-end; padding-right: $sp-1;
background: $color;
transform: translateX(-102%); // 자기 폭보다 조금 더 밖으로 — 이음선이 티 나지 않게
animation-name: scrPanelLOnce; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: 1; animation-fill-mode: backwards;
}
.scr__panel--r {
right: 0; justify-content: flex-start; padding-left: $sp-1;
background: $subject-ink;
transform: translateX(102%);
animation-name: scrPanelROnce; animation-duration: $duration;
animation-timing-function: $easing; animation-iteration-count: 1; animation-fill-mode: backwards;
}
.scr__word {
color: $subject-cream; // 파란 반쪽(blue-deep) 위 5.98:1 · 먹 반쪽 위 17.11:1
font-weight: 800; font-size: clamp(20px, 6vw, 32px); line-height: 1.2; white-space: nowrap;
}
// ── 가운데 세로선. 닫힌 순간 반쪽 위에 보이고, 열리기 직전에 먼저 스러진다
.scr__seam {
position: absolute; top: 10%; bottom: 10%; left: 50%; width: 4px; z-index: 3;
border-radius: $r-pill; background: $stage-orange;
transform: translateX(-50%) scaleY(0);
animation-name: scrSeamOnce; animation-duration: $dur-quick;
animation-timing-function: $easing; animation-iteration-count: 1; animation-fill-mode: backwards;
}
.scr__skip {
position: absolute; top: $sp-3; right: $sp-3; z-index: 4; margin: 0;
border: 0; border-radius: $r-pill; padding: $sp-1 $sp-3;
background: $subject-cream; color: $subject-ink; // 17.11:1 — 어느 반쪽 위에 놓여도 통과
font-family: inherit; font-weight: 700; font-size: 11px; line-height: 1.4;
cursor: pointer; box-shadow: $shadow-press;
}
.scr__skip:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
// 화면 낭독기용 상태 한 줄
.scr__sr {
position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
// ── 실제 인트로(한 번 재생). 0% 가 닫힌 장면, 100% 가 기본 상태(완료)와 같다.
// 콘텐츠는 사라지지 않고 반쪽 아래에서 안착 동작만 한다
@keyframes scrPanelLOnce {
0% { transform: translateX(0); }
100% { transform: translateX(-102%); }
}
@keyframes scrPanelROnce {
0% { transform: translateX(0); }
100% { transform: translateX(102%); }
}
@keyframes scrContentOnce {
0% { transform: translateY(12px); }
60%, 100% { transform: translateY(0); }
}
@keyframes scrSeamOnce {
0% { transform: translateX(-50%) scaleY(1); }
40%, 100% { transform: translateX(-50%) scaleY(0); }
}
// ── 격자 미리보기 루프(2초). 열림은 6→45% 스프링, 되닫음은 70→90% 선형이라
// 한 프레임 변화량이 열림 중간보다 커지지 않는다 — poster 가 되닫는 장면에 걸리지 않게 한다
.scr.is-demo .scr__panel--l {
animation-name: scrPanelLLoop; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.scr.is-demo .scr__panel--r {
animation-name: scrPanelRLoop; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.scr.is-demo .scr__content {
animation-name: scrContentLoop; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.scr.is-demo .scr__seam {
animation-name: scrSeamLoop; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes scrPanelLLoop {
0%, 6% { transform: translateX(0); }
45% { transform: translateX(-102%); }
70% { transform: translateX(-102%); animation-timing-function: linear; }
90%, 100% { transform: translateX(0); }
}
@keyframes scrPanelRLoop {
0%, 6% { transform: translateX(0); }
45% { transform: translateX(102%); }
70% { transform: translateX(102%); animation-timing-function: linear; }
90%, 100% { transform: translateX(0); }
}
// 콘텐츠는 반쪽에 덮여 있는 동안 못 앉은 상태(12px 아래)에서 틈이 벌어질 때 안착하고,
// 다시 덮인 뒤(90%) 다음 바퀴 시작 상태로 돌아간다 — 화면에 보이는 순간엔 언제나 불투명하다
@keyframes scrContentLoop {
0%, 6% { transform: translateY(12px); }
34%, 90% { transform: translateY(0); }
92%, 100% { transform: translateY(12px); }
}
@keyframes scrSeamLoop {
0% { transform: translateX(-50%) scaleY(1); }
8%, 86% { transform: translateX(-50%) scaleY(0); }
90%, 100% { transform: translateX(-50%) scaleY(1); }
}
// 건너뛰기·Esc·재방문 — 애니메이션을 꺼 기본 상태(완료)로 돌린다 (is-demo 규칙 뒤에 둔다)
.scr.is-done .scr__panel--l, .scr.is-done .scr__panel--r,
.scr.is-done .scr__content, .scr.is-done .scr__seam { animation-name: none; }
@media (max-width: 340px) {
.scr__content { gap: $sp-1; }
.scr__skip { top: $sp-2; right: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.scr__panel--l, .scr__panel--r, .scr__content, .scr__seam { animation-name: 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);
}
.scr {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.scr__content {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-content: center;
justify-items: center;
gap: 8px;
text-align: center;
animation-name: scrContentOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.scr__kicker {
margin: 0;
color: #17141a;
font-weight: 700;
font-size: clamp(11px, 2.6vw, 13px);
line-height: 1.4;
letter-spacing: 0.14em;
}
.scr__title {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: clamp(22px, 6vw, 34px);
line-height: 1.2;
}
.scr__sub {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: clamp(12px, 3vw, 14px);
line-height: 1.5;
}
.scr__cta {
margin: 0;
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: clamp(12px, 3vw, 14px);
line-height: 1.4;
cursor: pointer;
}
.scr__cta:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.scr__panel {
position: absolute;
top: 0;
bottom: 0;
width: 50.5%;
z-index: 2;
display: flex;
align-items: center;
will-change: transform;
}
.scr__panel--l {
left: 0;
justify-content: flex-end;
padding-right: 4px;
background: #0a4fe8;
transform: translateX(-102%);
animation-name: scrPanelLOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.scr__panel--r {
right: 0;
justify-content: flex-start;
padding-left: 4px;
background: #17141a;
transform: translateX(102%);
animation-name: scrPanelROnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.scr__word {
color: #fff7e6;
font-weight: 800;
font-size: clamp(20px, 6vw, 32px);
line-height: 1.2;
white-space: nowrap;
}
.scr__seam {
position: absolute;
top: 10%;
bottom: 10%;
left: 50%;
width: 4px;
z-index: 3;
border-radius: 999px;
background: #ff4d1f;
transform: translateX(-50%) scaleY(0);
animation-name: scrSeamOnce;
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
.scr__skip {
position: absolute;
top: 12px;
right: 12px;
z-index: 4;
margin: 0;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.scr__skip:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.scr__sr {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes scrPanelLOnce {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-102%);
}
}
@keyframes scrPanelROnce {
0% {
transform: translateX(0);
}
100% {
transform: translateX(102%);
}
}
@keyframes scrContentOnce {
0% {
transform: translateY(12px);
}
60%, 100% {
transform: translateY(0);
}
}
@keyframes scrSeamOnce {
0% {
transform: translateX(-50%) scaleY(1);
}
40%, 100% {
transform: translateX(-50%) scaleY(0);
}
}
.scr.is-demo .scr__panel--l {
animation-name: scrPanelLLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.scr.is-demo .scr__panel--r {
animation-name: scrPanelRLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.scr.is-demo .scr__content {
animation-name: scrContentLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.scr.is-demo .scr__seam {
animation-name: scrSeamLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes scrPanelLLoop {
0%, 6% {
transform: translateX(0);
}
45% {
transform: translateX(-102%);
}
70% {
transform: translateX(-102%);
animation-timing-function: linear;
}
90%, 100% {
transform: translateX(0);
}
}
@keyframes scrPanelRLoop {
0%, 6% {
transform: translateX(0);
}
45% {
transform: translateX(102%);
}
70% {
transform: translateX(102%);
animation-timing-function: linear;
}
90%, 100% {
transform: translateX(0);
}
}
@keyframes scrContentLoop {
0%, 6% {
transform: translateY(12px);
}
34%, 90% {
transform: translateY(0);
}
92%, 100% {
transform: translateY(12px);
}
}
@keyframes scrSeamLoop {
0% {
transform: translateX(-50%) scaleY(1);
}
8%, 86% {
transform: translateX(-50%) scaleY(0);
}
90%, 100% {
transform: translateX(-50%) scaleY(1);
}
}
.scr.is-done .scr__panel--l, .scr.is-done .scr__panel--r,
.scr.is-done .scr__content, .scr.is-done .scr__seam {
animation-name: none;
}
@media (max-width: 340px) {
.scr__content {
gap: 4px;
}
.scr__skip {
top: 8px;
right: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.scr__panel--l, .scr__panel--r, .scr__content, .scr__seam {
animation-name: none !important;
}
}(function () {
var root = document.getElementById('scr');
var content = document.getElementById('content');
var status = document.getElementById('status');
var KEY = 'godrich-intro07-seen';
var MSG_DONE = 'イントロが終わりました';
function finish() {
if (root.classList.contains('is-done')) { return; }
root.classList.add('is-done');
status.textContent = MSG_DONE;
try { sessionStorage.setItem(KEY, '1'); } catch (e) {}
}
// 한 번 본 브라우저에서는 처음부터 완료 상태
try { if (sessionStorage.getItem(KEY)) { finish(); } } catch (e) {}
document.getElementById('skip').addEventListener('click', finish);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { finish(); }
});
// 격자 미리보기 루프(.is-demo)는 손이 닿으면 실제 인트로(한 번 재생)로 넘어간다
root.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); }, { once: true });
// 실제 인트로의 마지막 애니메이션(콘텐츠 안착)이 끝나면 완료로 기록한다
content.addEventListener('animationend', function (e) {
if (e.target === content) { finish(); }
});
})();// SplitScreenOpen — vanilla 판(run/016/demo/07-intro-split-screen-open/index.html) 의 React 포트.
// 좌우 반쪽이 translateX 로 반대 방향으로 빠지며 첫 화면이 드러난다.
// @keyframes 본문은 vanilla 와 같은 글자다 (값을 다시 타이핑하지 않는다).
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SplitScreenOpen.module.scss";
export type SplitScreenOpenProps = {
duration?: number; // ms — 실제 인트로(한 번 재생) 길이
easing?: string;
color?: string; // 왼쪽 반쪽 면색
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string };
type AnimEvt = { target: unknown; currentTarget: unknown };
const KEY = "godrich-intro07-seen";
const LABEL = "사이트 첫 화면";
const KICKER = "에디션 12";
const TITLE = "이야기 공작소";
const SUB = "매주 금요일, 짧은 이야기 하나";
const CTA = "구독하기";
const WORD_L = "이야기";
const WORD_R = "공작소";
const SKIP = "건너뛰기";
const STATUS_PLAY = "인트로 재생 중";
const STATUS_DONE = "인트로가 끝났습니다";
export default function SplitScreenOpen(props: SplitScreenOpenProps) {
const duration = props.duration ?? 600;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#0a4fe8";
// 한 번 본 브라우저에서는 처음부터 완료 상태
const [done, setDone] = useState<boolean>(() => {
try { return sessionStorage.getItem(KEY) === "1"; } catch { return false; }
});
const [preview, setPreview] = useState<boolean>(true);
useEffect(() => {
if (!done) return;
try { sessionStorage.setItem(KEY, "1"); } catch { /* 저장 불가 환경 무시 */ }
}, [done]);
useEffect(() => {
const onKey = (e: KeyEvt) => { if (e.key === "Escape") setDone(true); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<section
className={styles.scr + (preview ? " " + styles.isDemo : "") + (done ? " " + styles.isDone : "")}
style={vars}
aria-label={LABEL}
onPointerDown={() => setPreview(false)}
>
<div
className={styles.content}
onAnimationEnd={(e: AnimEvt) => { if (e.target === e.currentTarget) setDone(true); }}
>
<p className={styles.kicker}>{KICKER}</p>
<h1 className={styles.title}>{TITLE}</h1>
<p className={styles.sub}>{SUB}</p>
<button className={styles.cta} type="button">{CTA}</button>
</div>
<div className={styles.panel + " " + styles.panelL} aria-hidden="true">
<span className={styles.word}>{WORD_L}</span>
</div>
<div className={styles.panel + " " + styles.panelR} aria-hidden="true">
<span className={styles.word}>{WORD_R}</span>
</div>
<span className={styles.seam} aria-hidden="true" />
<button className={styles.skip} type="button" onClick={() => setDone(true)}>{SKIP}</button>
<p className={styles.sr} aria-live="polite">{done ? STATUS_DONE : STATUS_PLAY}</p>
</section>
</main>
);
}07 スプリットスクリーン — 計測 累積面積 78.636% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · オープニング アニメーション css 9選 — イントロ アニメーション 入門
このデモが属する記事
オープニング アニメーション css 9選 — イントロ アニメーション 入門オープニング アニメーション css とは、ページが開いた最初の1〜2秒だけ画面を覆い、やがて退いて本来の画面を引き渡す演出のことです。スキップできてこそイントロ — 9つすべてにボタンと Esc と再訪の記録が備わり、その場で終わらせられ この記事のデモをすべて見る


