07Split screen open
The left and right halves slide apart with translateX, the seam in the middle opens, and once the gap is as wide as the screen the content settles in.
Best forEntry sequence into a product detail page
translateXwill-change$dur-enter

<main class="stage">
<section class="scr is-demo" id="scr" aria-label="Site first screen">
<div class="scr__content" id="content">
<p class="scr__kicker">Edition 12</p>
<h1 class="scr__title">Story Workshop</h1>
<p class="scr__sub">One short story, every Friday</p>
<button class="scr__cta" type="button">Subscribe</button>
</div>
<div class="scr__panel scr__panel--l" aria-hidden="true"><span class="scr__word">Story</span></div>
<div class="scr__panel scr__panel--r" aria-hidden="true"><span class="scr__word">Workshop</span></div>
<span class="scr__seam" aria-hidden="true"></span>
<button class="scr__skip" id="skip" type="button">Skip</button>
<p class="scr__sr" id="status" aria-live="polite">Playing intro</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 = 'Intro finished';
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 Split screen open — Measured area 78.636% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Intro Animation CSS: 9 Website Intro Animations
The post this demo lives in
Intro Animation CSS: 9 Website Intro AnimationsIntro animation css is the short opening a page plays over its first screen, and all nine here end the same way: an intr See all demos in this post


