01カーテンリフト
画面を覆う二枚の幕が上下に分かれて退き、その間から見出しが一拍遅れて translateY で浮かび上がります。スキップを押すと幕はその場で開き切ります。
使いどころブランドLPの最初の入口
translateYanimation-fill-mode$ease-spring

<main class="stage">
<div class="cl is-demo" id="cl">
<section class="cl__site">
<h1 class="cl__title">新しい物語がはじまります</h1>
<p class="cl__sub">毎週新しい物語を</p>
<button class="cl__cta" type="button">はじめる</button>
</section>
<div class="cl__veil cl__veil--top" aria-hidden="true"></div>
<div class="cl__veil cl__veil--bot" aria-hidden="true"><span class="cl__brand">GODRICH</span></div>
<button class="cl__skip" id="cl-skip" type="button">スキップ</button>
<p class="cl__live" id="cl-live" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 커튼 리프트 — 화면을 덮은 두 겹 가림막이 위아래로 갈라져 물러나고, 제목이 한 박자 늦게 translateY 로 떠오르는 인트로.
// 원칙: 요소의 기본(애니메이션 없는) 상태 = 인트로가 끝난 화면. "등장 전"은 0% 키프레임이 채운다(animation-fill-mode).
// 그래서 건너뛰기 버튼·Esc·sessionStorage 재방문은 .is-done 로 애니메이션을 끄는 것만으로 완료 상태가 되고,
// prefers-reduced-motion 에서도 애니메이션을 끄면 가림막은 기본 transform(화면 밖)에 있어 처음부터 열려 있다.
// 대비 실측(run/016/_probe/a1_대비.py): 제목 $subject-cream on $stage-ink 17.11:1 · 보조문 rgba($subject-cream,.72) 합성 9.18:1
// · 가림막 브랜드 $subject-cream on $subject-blue 4.25:1(20px 700 큰 글자, 기준 3) · CTA $subject-cream on $subject-blue-deep 5.98:1
$duration: $dur-enter; // 정적(실사용) 재생 시간 — 변형1: $dur-base
$easing: $ease-spring; // 변형2: $ease-pop
$color: $subject-blue; // 가림막 색 — 변형3: $stage-orange
@include stage($stage-ink);
.cl {
position: fixed; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: $sp-2; text-align: center;
}
// ── 가림막 아래 깔린 사이트 첫 화면
.cl__title {
margin: 0; color: $subject-cream;
font-weight: 800; font-size: clamp(22px, 5.5vw, 30px); line-height: 1.25;
}
.cl__sub {
margin: 0; color: rgba($subject-cream, .72);
font-weight: 500; font-size: clamp(12px, 3vw, 14px); line-height: 1.5;
}
.cl__cta {
margin-top: $sp-2; padding: $sp-2 $sp-5; border: 0; border-radius: $r-pill;
background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 13px; line-height: 1.4; cursor: pointer;
}
// ── 두 겹 가림막. 기본 transform 은 "열린" 위치(화면 밖)다. 앞전(가장자리)에 크림 섬을 둬 걷히는 방향을 보여준다
.cl__veil { position: absolute; left: 0; right: 0; height: 50.5%; background: $color; z-index: 2; }
.cl__veil::after { content: ""; position: absolute; left: 0; right: 0; height: $sp-1; background: $subject-cream; }
.cl__veil--top { top: 0; transform: translateY(-102%); }
.cl__veil--top::after { bottom: 0; }
.cl__veil--bot { bottom: 0; transform: translateY(102%); }
.cl__veil--bot::after { top: 0; }
// 닫힌 상태의 타이틀카드 글자. 아래 판에 인쇄되어 있어 판과 함께 물러난다 (20px 700 = 큰 글자)
.cl__brand {
position: absolute; top: $sp-4; left: 0; right: 0; margin: 0; text-align: center;
color: $subject-cream; font-weight: 700; font-size: 20px; line-height: 1.2;
letter-spacing: .35em; text-indent: .35em;
}
// ── 건너뛰기 (무대 우상단). 가림막보다 위
.cl__skip {
position: absolute; top: $sp-3; right: $sp-3; z-index: 4;
padding: $sp-1 $sp-3; border: 1px solid rgba($subject-cream, .32); border-radius: $r-pill;
background: rgba($subject-cream, .10); color: $subject-cream;
font-family: inherit; font-weight: 600; font-size: 12px; line-height: 1.4; cursor: pointer;
}
// 스크린리더용 상태 한 줄
.cl__live {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
// ── 정적(실사용): 페이지를 열면 한 번 재생되고 열린 채로 끝난다.
// 낱개 속성으로 쓴다 — animation: 단축은 delay 를 0 으로 리셋한다.
// 제목·보조문·CTA 는 opacity 로 나타나지 않는다 — 처음부터 제자리에서 가림막에 가려져 있다가
// 가림막이 걷히며 모습을 드러낸다 (반투명 유령 프레임이 아예 없다)
.cl__veil--top {
animation-name: clTopOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: .08s; animation-fill-mode: both;
}
.cl__veil--bot {
animation-name: clBotOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: .16s; animation-fill-mode: both;
}
.cl__title {
animation-name: clRiseOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: .34s; animation-fill-mode: both;
}
.cl__sub {
animation-name: clRiseOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: .46s; animation-fill-mode: both;
}
.cl__cta {
animation-name: clRiseOnce; animation-duration: $duration; animation-timing-function: $easing;
animation-delay: .58s; animation-fill-mode: both;
}
@keyframes clTopOnce { from { transform: translateY(0); } to { transform: translateY(-102%); } }
@keyframes clBotOnce { from { transform: translateY(0); } to { transform: translateY(102%); } }
@keyframes clRiseOnce { from { transform: translateY(24px); } to { transform: none; } }
// ── 격자 미리보기 루프(2초). 열림 4~32%(스프링 급출발) · 콘텐츠는 가림막 뒤에서 14~50% 에 떠오르고
// 66~94% 에 가림막이 되돌아와 다시 덮는다(콘텐츠는 그 아래에서 그대로 hold — 퇴장 연출이 없다).
// 복귀는 linear 로 충분히 느리게(프레임당 약 10% 캔버스) — 여는 쪽 스프링(약 25%)이 늘 이기므로
// poster 는 "걷히는 중 + 제목 떠오름"이지 "닫히는 순간"이 아니다.
// 마지막 장(95.8%)은 frame 0 와 같은 완전히 닫힌 상태라 루프 이음이 깨끗하다
.cl.is-demo .cl__veil--top {
animation-name: clTopLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
.cl.is-demo .cl__veil--bot {
animation-name: clBotLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
.cl.is-demo .cl__title {
animation-name: clTitleLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
.cl.is-demo .cl__sub {
animation-name: clSubLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
.cl.is-demo .cl__cta {
animation-name: clCtaLoop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
@keyframes clTopLoop {
0%, 4% { transform: translateY(0); }
26%, 66% { transform: translateY(-102%); animation-timing-function: linear; }
88%, 100% { transform: translateY(0); }
}
@keyframes clBotLoop {
0%, 10% { transform: translateY(0); }
32%, 72% { transform: translateY(102%); animation-timing-function: linear; }
94%, 100% { transform: translateY(0); }
}
@keyframes clTitleLoop {
0%, 14% { transform: translateY(24px); }
36%, 100% { transform: none; }
}
@keyframes clSubLoop {
0%, 20% { transform: translateY(16px); }
44%, 100% { transform: none; }
}
@keyframes clCtaLoop {
0%, 26% { transform: translateY(12px); }
50%, 100% { transform: none; }
}
// 건너뛰기·Esc·재방문: 애니메이션을 끄면 기본 상태(완료)가 드러난다
.cl.is-done .cl__veil--top, .cl.is-done .cl__veil--bot,
.cl.is-done .cl__title, .cl.is-done .cl__sub, .cl.is-done .cl__cta { animation: none; }
.cl.is-done .cl__skip { display: none; }
// ── 좁은 화면: 간격과 글자를 줄여 무대 안에 들어온다. 브랜드 글자는 20px 700(큰 글자)을 유지해
// 크림 on 파랑 대비 기준(3:1)을 지킨다
@media (max-width: 340px) {
.cl { gap: $sp-1; }
.cl__title { font-size: 20px; }
.cl__brand { letter-spacing: .24em; text-indent: .24em; }
}
@media (prefers-reduced-motion: reduce) {
// !important — .cl.is-demo 계열(클래스 3개) 특이도에 지지 않으려면 필요하다
.cl .cl__veil--top, .cl .cl__veil--bot,
.cl .cl__title, .cl .cl__sub, .cl .cl__cta { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
}
.cl__title {
margin: 0;
color: #fff7e6;
font-weight: 800;
font-size: clamp(22px, 5.5vw, 30px);
line-height: 1.25;
}
.cl__sub {
margin: 0;
color: rgba(255, 247, 230, 0.72);
font-weight: 500;
font-size: clamp(12px, 3vw, 14px);
line-height: 1.5;
}
.cl__cta {
margin-top: 8px;
padding: 8px 20px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 13px;
line-height: 1.4;
cursor: pointer;
}
.cl__veil {
position: absolute;
left: 0;
right: 0;
height: 50.5%;
background: #2f6df6;
z-index: 2;
}
.cl__veil::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 4px;
background: #fff7e6;
}
.cl__veil--top {
top: 0;
transform: translateY(-102%);
}
.cl__veil--top::after {
bottom: 0;
}
.cl__veil--bot {
bottom: 0;
transform: translateY(102%);
}
.cl__veil--bot::after {
top: 0;
}
.cl__brand {
position: absolute;
top: 16px;
left: 0;
right: 0;
margin: 0;
text-align: center;
color: #fff7e6;
font-weight: 700;
font-size: 20px;
line-height: 1.2;
letter-spacing: 0.35em;
text-indent: 0.35em;
}
.cl__skip {
position: absolute;
top: 12px;
right: 12px;
z-index: 4;
padding: 4px 12px;
border: 1px solid rgba(255, 247, 230, 0.32);
border-radius: 999px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
}
.cl__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cl__veil--top {
animation-name: clTopOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: 0.08s;
animation-fill-mode: both;
}
.cl__veil--bot {
animation-name: clBotOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: 0.16s;
animation-fill-mode: both;
}
.cl__title {
animation-name: clRiseOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: 0.34s;
animation-fill-mode: both;
}
.cl__sub {
animation-name: clRiseOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: 0.46s;
animation-fill-mode: both;
}
.cl__cta {
animation-name: clRiseOnce;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-delay: 0.58s;
animation-fill-mode: both;
}
@keyframes clTopOnce {
from {
transform: translateY(0);
}
to {
transform: translateY(-102%);
}
}
@keyframes clBotOnce {
from {
transform: translateY(0);
}
to {
transform: translateY(102%);
}
}
@keyframes clRiseOnce {
from {
transform: translateY(24px);
}
to {
transform: none;
}
}
.cl.is-demo .cl__veil--top {
animation-name: clTopLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
.cl.is-demo .cl__veil--bot {
animation-name: clBotLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
.cl.is-demo .cl__title {
animation-name: clTitleLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
.cl.is-demo .cl__sub {
animation-name: clSubLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
.cl.is-demo .cl__cta {
animation-name: clCtaLoop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
@keyframes clTopLoop {
0%, 4% {
transform: translateY(0);
}
26%, 66% {
transform: translateY(-102%);
animation-timing-function: linear;
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes clBotLoop {
0%, 10% {
transform: translateY(0);
}
32%, 72% {
transform: translateY(102%);
animation-timing-function: linear;
}
94%, 100% {
transform: translateY(0);
}
}
@keyframes clTitleLoop {
0%, 14% {
transform: translateY(24px);
}
36%, 100% {
transform: none;
}
}
@keyframes clSubLoop {
0%, 20% {
transform: translateY(16px);
}
44%, 100% {
transform: none;
}
}
@keyframes clCtaLoop {
0%, 26% {
transform: translateY(12px);
}
50%, 100% {
transform: none;
}
}
.cl.is-done .cl__veil--top, .cl.is-done .cl__veil--bot,
.cl.is-done .cl__title, .cl.is-done .cl__sub, .cl.is-done .cl__cta {
animation: none;
}
.cl.is-done .cl__skip {
display: none;
}
@media (max-width: 340px) {
.cl {
gap: 4px;
}
.cl__title {
font-size: 20px;
}
.cl__brand {
letter-spacing: 0.24em;
text-indent: 0.24em;
}
}
@media (prefers-reduced-motion: reduce) {
.cl .cl__veil--top, .cl .cl__veil--bot,
.cl .cl__title, .cl .cl__sub, .cl .cl__cta {
animation: none !important;
}
}(function () {
var root = document.getElementById('cl');
var live = document.getElementById('cl-live');
var KEY = 'godrich-intro-016-01';
// 인트로를 완료 상태로 보낸다 — 애니메이션을 끄면 기본 상태(열린 화면)가 그대로 드러난다
function finish(announce) {
root.classList.remove('is-demo');
root.classList.add('is-done');
try { sessionStorage.setItem(KEY, '1'); } catch (e) {}
if (announce) live.textContent = 'イントロをスキップしました';
}
document.getElementById('cl-skip').addEventListener('click', function () { finish(true); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') finish(true);
});
var seen = false;
try { seen = sessionStorage.getItem(KEY) !== null; } catch (e) {}
if (seen) finish(false);
})();// CurtainLift — vanilla 판(run/016/demo/01-intro-curtain-lift/index.html) 의 React 포트.
// 두 겹 가림막이 위아래로 갈라지고 제목이 한 박자 늦게 translateY 로 떠오르는 인트로.
// @keyframes 본문은 vanilla 와 같은 글자다 (값을 다시 타이핑하지 않는다).
// 건너뛰기·Esc·sessionStorage 재방문은 애니메이션을 끊는 것(= 기본 상태)으로 완료 상태를 만든다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CurtainLift.module.scss";
export type CurtainLiftProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 타입에 맞춰 로컬 타입을 쓴다 — 이벤트 제네릭을 react 에서 import 하면 tsc 가 죽는다
type KeyEvt = { key: string };
const KEY = "godrich-intro-016-01";
const TITLE = "새 이야기를 엽니다";
const SUB = "매주 새로운 이야기";
const CTA = "시작하기";
const BRAND = "GODRICH";
const SKIP = "건너뛰기";
const SKIPPED = "인트로를 건너뛰었습니다";
export default function CurtainLift(props: CurtainLiftProps) {
const duration = props.duration ?? 600;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const color = props.color ?? "#2f6df6";
const [preview, setPreview] = useState<boolean>(true);
const [done, setDone] = useState<boolean>(false);
const [message, setMessage] = useState<string>("");
// 인트로를 완료 상태로 보낸다 — 애니메이션을 끄면 기본 상태(열린 화면)가 그대로 드러난다
const finish = (announce: boolean) => {
setPreview(false);
setDone(true);
try { window.sessionStorage.setItem(KEY, "1"); } catch { }
if (announce) setMessage(SKIPPED);
};
useEffect(() => {
let seen = false;
try { seen = window.sessionStorage.getItem(KEY) !== null; } catch { seen = false; }
if (seen) {
setPreview(false);
setDone(true);
}
}, []);
useEffect(() => {
const onKey = (e: KeyEvt) => { if (e.key === "Escape") finish(true); };
window.addEventListener("keydown", onKey as (e: KeyboardEvent) => void);
return () => window.removeEventListener("keydown", onKey as (e: KeyboardEvent) => void);
}, []);
const vars: Vars = { "--duration": duration + "ms", "--easing": easing, "--color": color };
const rootCls = styles.cl + (preview && !done ? " " + styles.isDemo : "") + (done ? " " + styles.isDone : "");
return (
<main className={styles.stage}>
<div className={rootCls} style={vars}>
<section className={styles.site}>
<h1 className={styles.title}>{TITLE}</h1>
<p className={styles.sub}>{SUB}</p>
<button className={styles.cta} type="button">{CTA}</button>
</section>
<div className={styles.veil + " " + styles.veilTop} aria-hidden="true" />
<div className={styles.veil + " " + styles.veilBot} aria-hidden="true">
<span className={styles.brand}>{BRAND}</span>
</div>
{!done ? (
<button className={styles.skip} type="button" onClick={() => finish(true)}>{SKIP}</button>
) : null}
<p className={styles.live} aria-live="polite">{message}</p>
</div>
</main>
);
}01 カーテンリフト — 計測 累積面積 99.947% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · オープニング アニメーション css 9選 — イントロ アニメーション 入門
このデモが属する記事
オープニング アニメーション css 9選 — イントロ アニメーション 入門オープニング アニメーション css とは、ページが開いた最初の1〜2秒だけ画面を覆い、やがて退いて本来の画面を引き渡す演出のことです。スキップできてこそイントロ — 9つすべてにボタンと Esc と再訪の記録が備わり、その場で終わらせられ この記事のデモをすべて見る


