GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

css 인트로 애니메이션 9종 — 인트로 만들기 핵심

css 인트로 애니메이션은 페이지가 열리는 첫 1~2초 동안 화면을 덮었다가 물러나며 본 화면을 내주는 연출입니다. 건너뛸 수 있어야 인트로다 — 아홉 개 모두 버튼과 Esc, 재방문 기록으로 그 자리에서 끝납니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

순서는 인기순이 아니라 덮개가 걷히는 단위가 넓은 면에서 좁은 획으로 옮겨 갔다가 다시 화면 전체로 돌아오는 순서입니다. 화면을 반씩 나눈 가림막이 먼저 물러나고(01), 흩어진 조각이 모여 로고가 되고(02), 그다음은 낱글자(03)·원 하나(04)·숫자 셋(05)·선 한 줄(06)로 주인공이 점점 작아집니다. 07 부터는 다시 화면 전체가 움직여 좌우로 갈라지고(07), 제목이 어긋났다 붙고(08), 색 띠가 훑고 지나갑니다(09). 아홉 개 모두 애니메이션이 없는 기본 상태가 곧 인트로가 끝난 화면이라, 건너뛰기는 클래스 하나로 애니메이션을 끄는 일이 됩니다. 오프닝 애니메이션은 처음 한 번만 보여야 하니 이 구조가 먼저고 연출은 그다음입니다. 기다리는 동안의 화면을 다루는 로딩페이지 디자인 9종, 화면과 화면 사이를 다루는 CSS 페이지 전환 애니메이션 9종이 이 편의 이웃입니다.

01커튼 리프트

화면을 덮은 두 겹 가림막이 위아래로 갈라져 물러나고 제목이 한 박자 늦게 떠오르는, 브랜드 랜딩의 첫 진입에 쓰는 인트로입니다.

커튼 리프트
새 창에서 열기

01 · 커튼 리프트 — 측정 누적면적 99.947% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateYanimation-fill-mode$ease-spring

기본 상태를 끝난 화면으로 두기

// ── 두 겹 가림막. 기본 transform 은 "열린" 위치(화면 밖)다. 앞전(가장자리)에 크림 섬을 둬 걷히는 방향을 보여준다
.cl__veil { position: absolute; left: 0; right: 0; height: 50.5%; background: $color; z-index: 2; }
.cl__veil--top { top: 0; transform: translateY(-102%); }
.cl__veil--bot { bottom: 0; transform: translateY(102%); }
.cl__veil--top {
  animation-name: clTopOnce; animation-duration: $duration; animation-timing-function: $easing;
  animation-delay: .08s; 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; } }

가림막의 기본 transform 이 이미 화면 밖이라 애니메이션을 끄는 순간이 곧 완료 화면이고, 닫혀 있는 시작 장면은 from 키프레임과 animation-fill-mode: both 가 대신 채웁니다.

건너뛰기와 재방문을 한 함수로

// 인트로를 완료 상태로 보낸다 — 애니메이션을 끄면 기본 상태(열린 화면)가 그대로 드러난다
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);
});

클래스 두 개를 갈아 끼우는 함수 하나가 버튼·Esc·재방문 세 경로를 함께 받고, 이 항목의 루프는 누적면적 99.947%·움직인 프레임 19/23·애니메이션 5개로 측정됐습니다.

02로고 조각 결합

사방에서 날아온 조각 네 개가 시차를 두고 가운데로 모여 마지막 조각이 닿는 순간 로고 한 덩어리가 되는, 로고를 먼저 보여야 하는 회사 소개용 인트로입니다.

로고 조각 결합
새 창에서 열기

02 · 로고 조각 결합 — 측정 누적면적 21.906% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

clip-pathtransform-originanimation-delay

원 하나를 4분면으로 잘라 조각을 만든다

.la__q {
  position: absolute; inset: 0; border-radius: 50%;
  transform-origin: 50% 50%;   // 회전축 = 원의 중심 = 조각이 로고에 붙는 지점
}
.la__q--tl { background: $subject-ink;    clip-path: inset(0 51% 51% 0); }
.la__q--tr { background: $color;          clip-path: inset(0 0 51% 51%); }
.la__q--br { background: $chart-3;        clip-path: inset(51% 0 0 51%); }
.la__q--bl { background: $stage-orange;   clip-path: inset(51% 51% 0 0); }

조각 넷은 같은 자리에 겹쳐 둔 같은 크기의 원이고 clip-path: inset 이 각자 보일 사분면만 남겨, 넷이 제자리에 모이면 51%로 잡은 여백이 2px 남짓의 틈으로 보입니다.

지연을 한 칸씩 미뤄 도착 순서를 만든다

.la__q--tr {
  animation-name: laInTr; animation-duration: $duration; animation-timing-function: $easing;
  animation-delay: .08s; animation-fill-mode: both;
}
@keyframes laInTl { from { transform: translate(-150px, 0) rotate(-10deg); } to { transform: none; } }
@keyframes laInTr { from { transform: translate(0, -150px) rotate(8deg); } to { transform: none; } }
@keyframes laInBr { from { transform: translate(150px, 0) rotate(10deg); } to { transform: none; } }
@keyframes laInBl { from { transform: translate(0, 150px) rotate(-8deg); } to { transform: none; } }

조각마다 출발 방향과 기울기가 다르고 지연만 0.08초씩 밀려 있어, 누적면적 21.906%로 화면의 5분의 1만 쓰면서도 움직인 프레임은 19/23 이 나옵니다.

03낱글자 마스크 스태거

글자마다 잘림 상자를 씌워 두고 아래에서 한 글자씩 밀어 올려 바닥에서 자라는 것처럼 보이게 하는, 타이틀 한 줄이 주인공인 히어로용 인트로입니다.

낱글자 마스크 스태거
새 창에서 열기

03 · 낱글자 마스크 스태거 — 측정 누적면적 6.092% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

overflow: hidden--ianimation-delay

상자가 곧 마스크고 글자는 그 안에서만 보인다

.tt__row { display: flex; gap: $sp-1; margin: 0; }
.tt__box { display: block; overflow: hidden; }
.tt__ch {
  display: block; color: $color;
  font-weight: 800; font-size: clamp(26px, 7vw, 36px); line-height: 1.15;
}
.tt__ch {
  animation-name: ttChOnce; animation-duration: $duration; animation-timing-function: $easing;
  animation-delay: calc(var(--i) * .05s); animation-fill-mode: both;
}
@keyframes ttChOnce { from { transform: translateY(112%); } to { transform: none; } }

글자를 112% 아래로 내려 두면 overflow: hidden 상자 밖이라 아예 보이지 않으므로, 이 데모에는 반투명하게 비치는 중간 상태가 생길 자리가 없습니다.

순번은 마크업이 변수로 넘긴다

<h1 class="tt__row" aria-label="GODRICH">
  <span class="tt__box" aria-hidden="true" style="--i:0"><span class="tt__ch">G</span></span>
  <span class="tt__box" aria-hidden="true" style="--i:1"><span class="tt__ch">O</span></span>
  <span class="tt__box" aria-hidden="true" style="--i:2"><span class="tt__ch">D</span></span>
  <span class="tt__box" aria-hidden="true" style="--i:3"><span class="tt__ch">R</span></span>
  <span class="tt__box" aria-hidden="true" style="--i:4"><span class="tt__ch">I</span></span>
  <span class="tt__box" aria-hidden="true" style="--i:5"><span class="tt__ch">C</span></span>
  <span class="tt__box" aria-hidden="true" style="--i:6"><span class="tt__ch">H</span></span>
</h1>

낱글자를 aria-hidden 으로 감추고 줄 전체에 aria-label 을 붙여 읽는 사람에겐 한 낱말로 들리게 했고, 글자가 번갈아 오르내리는 덕에 움직인 프레임이 21/23 으로 아홉 중 가장 촘촘합니다.

04원형 마스크 와이프

가운데 점 하나에서 시작한 원이 화면 밖까지 커지며 덮개를 지워 아래 깔려 있던 콘텐츠를 드러내는, 포트폴리오 첫 화면 전환용 인트로입니다.

원형 마스크 와이프
새 창에서 열기

04 · 원형 마스크 와이프 — 측정 누적면적 97.503% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

clip-path: circle$ease-outanimation-fill-mode

원이 커지는 것이 아니라 덮개가 지워진다

// 첫 화면 — clip-path 원이 이 요소를 마스크한다. 배경이 불투명이라 원 안에서 덮개를 완전히 덮는다
.iw__site {
  position: absolute; inset: 0; z-index: 2;
  display: grid; place-items: center;
  box-sizing: border-box; padding: $sp-4;
  background: $stage-paper;
}
@keyframes iwOpen {
  0% { clip-path: circle(0% at 50% 50%); }
  62% { clip-path: circle(75% at 50% 50%); }
  100% { clip-path: circle(75% at 50% 50%); }
}

움직이는 것은 잉크 덮개가 아니라 그 위에 놓인 첫 화면의 clip-path 한 줄이고, 원 안쪽은 불투명한 종이색이라 커지는 만큼 덮개가 잘려 나갑니다.

반지름 75%가 모서리에 닿는 계산

// 75% 면 이 무대의 모서리까지 닿는다(480×300 기준 참조반지름 400px, 모서리 거리 283px)
.iw.is-demo .iw__site {
  animation-name: iwOpen;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-fill-mode: both;
}

circle() 의 퍼센트는 가로세로를 합쳐 낸 참조 반지름 기준이라 480×300 무대에서 75%는 300px, 중심에서 모서리까지의 283px보다 길어 네 귀퉁이까지 지워지고, 덕분에 누적면적이 97.503%로 화면 거의 전부를 덮습니다.

05카운트다운 리드

3·2·1 이 한 프레임에 갈아 끼워지고 숫자마다 크기가 한 번씩 줄며 세는 동안 화면이 가까워지는, 이벤트나 라이브 시작 화면용 인트로입니다.

카운트다운 리드
새 창에서 열기

05 · 카운트다운 리드 — 측정 누적면적 10.317% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

steps(1, end)font-variant-numericscale

숫자 셋을 같은 칸에 포개 놓는다

.cd__n {
  grid-area: 1 / 1;
  color: $color;
  font-weight: 800; font-size: clamp(72px, 24vw, 104px); line-height: 1;
  font-variant-numeric: tabular-nums;   // 3·2·1 폭이 같아 컷마다 중심이 흔들리지 않는다
  opacity: 0;                            // 기본(완료 상태)에는 숫자가 없다
}
@keyframes cdN3   { 0% { opacity: 1; } 14% { opacity: 0; } 100% { opacity: 0; } }
@keyframes cdN2   { 0% { opacity: 0; } 14% { opacity: 1; } 28% { opacity: 0; } 100% { opacity: 0; } }
@keyframes cdN1   { 0% { opacity: 0; } 28% { opacity: 1; } 42% { opacity: 0; } 100% { opacity: 0; } }

세 숫자를 grid-area: 1 / 1 로 같은 칸에 겹쳐 두고 보이는 하나만 골라 켜므로 자리 이동 없이 숫자만 바뀌고, 폭이 고정된 숫자 서체라 중심선도 흔들리지 않습니다.

팝을 숫자마다 따로 두는 이유

// 팝은 숫자마다 제 keyframes 하나 — 같은 속성(transform)에 애니메이션을 겹치면 목록 뒤쪽이 이겨 순서가 어긋난다
.cd.is-demo .cd__n--3 .cd__pop { animation-name: cdPop3; }
.cd.is-demo .cd__n--2 .cd__pop { animation-name: cdPop2; }
.cd.is-demo .cd__n--1 .cd__pop { animation-name: cdPop1; }
@keyframes cdPop3 { 0% { transform: scale(1.5); } 12% { transform: scale(1); } 100% { transform: scale(1); } }
@keyframes cdPop2 { 0%, 14% { transform: scale(1.5); } 26% { transform: scale(1); } 100% { transform: scale(1); } }
@keyframes cdPop1 { 0%, 28% { transform: scale(1.5); } 40% { transform: scale(1); } 100% { transform: scale(1); } }

컷과 이징을 한 키프레임에 섞지 않으려고 켜기는 숫자 요소가, 크기 팝은 그 안의 자식이 나눠 맡아서 애니메이션 10개가 들어갔고 그만큼 움직인 프레임은 14/23 으로 적습니다.

06라인 드로잉 테두리

테두리가 한 붓으로 그려지고 선이 닫히면 그 안의 글자가 뒤따라 켜지는, 전시나 매거진 표지에 어울리는 인트로입니다.

라인 드로잉 테두리
새 창에서 열기

06 · 라인 드로잉 테두리 — 측정 누적면적 6.66% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

stroke-dasharraystroke-dashoffsetpathLength

pathLength 100 이 길이 계산을 없앤다

.ld__rect {
  fill: none; stroke: $color; stroke-width: 3;
  stroke-dasharray: 100;   // pathLength 100 기준 — 그려질 최대 길이
  stroke-dashoffset: 0;    // 기본값 0 = 선이 다 그어진 완성 상태
}
@keyframes ldDraw {
  0% { stroke-dashoffset: 100; }
  56% { stroke-dashoffset: 0; }
  100% { stroke-dashoffset: 0; }
}

<rect> 에 pathLength="100" 을 적어 두면 브라우저가 실제 둘레를 100 으로 환산해 주므로 대시 길이를 재거나 스크립트로 둘레를 구할 일이 사라집니다.

선이 닫힌 뒤에 글자를 켠다

.ld.is-demo .ld__kicker,
.ld.is-demo .ld__title,
.ld.is-demo .ld__sub {
  animation-name: ldTextOn, ldTextRise;
  animation-duration: $duration, $duration;
  animation-timing-function: steps(1, end), $easing;
  animation-iteration-count: infinite, infinite;
  animation-fill-mode: backwards, backwards;
}
// 선이 닫히는 56%(1.12s) 부터 하나씩 — 상단 라벨, 제목, 부제
.ld.is-demo .ld__kicker { animation-delay: 1.12s, 1.12s; }
.ld.is-demo .ld__title { animation-delay: 1.2s, 1.2s; }
.ld.is-demo .ld__sub { animation-delay: 1.28s, 1.28s; }

켜짐과 올라옴을 쉼표로 나란히 걸어 한 요소가 컷과 곡선을 동시에 타게 했고, 선이 닫히는 1.12초부터 0.08초 간격으로 라벨·제목·부제가 차례로 들어와 애니메이션 7개·움직인 프레임 18/23 이 됩니다.

07스플릿 스크린 오픈

좌우 반쪽이 서로 반대 방향으로 빠지며 가운데 세로선이 벌어지고 틈이 화면 폭만큼 열리면 본문이 자리를 잡는, 제품 상세로 들어가는 진입 연출입니다.

스플릿 스크린 오픈
새 창에서 열기

07 · 스플릿 스크린 오픈 — 측정 누적면적 78.636% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateXwill-change$dur-enter

반쪽 둘이 서로 반대로 빠진다

.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;
}
@keyframes scrPanelLOnce {
  0% { transform: translateX(0); }
  100% { transform: translateX(-102%); }
}

반쪽 폭을 50.5%로 조금 넉넉히 잡고 102%까지 밀어내는 이유는 소수점 반올림으로 가운데나 가장자리에 실선 한 줄이 비치는 것을 막기 위해서입니다.

가운데 세로선이 먼저 스러진다

.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;
}
@keyframes scrSeamOnce {
  0% { transform: translateX(-50%) scaleY(1); }
  40%, 100% { transform: translateX(-50%) scaleY(0); }
}

이음선은 220ms짜리 짧은 애니메이션으로 반쪽이 벌어지기 전에 먼저 접혀 사라지므로 열리는 동안 화면 한가운데 선이 남지 않고, 면이 크게 움직이는 항목이라 누적면적은 78.636%입니다.

08글리치 부팅

제목이 잘린 두 겹으로 어긋나며 잔상 색이 좌우로 튀다가 어긋남이 0으로 수렴해 한 줄로 붙는, 게임이나 기술 브랜드의 진입 화면용 인트로입니다.

글리치 부팅
새 창에서 열기

08 · 글리치 부팅 — 측정 누적면적 9.744% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

text-shadowclip-pathanimation-iteration-count

제목을 위아래 두 겹으로 자른다

.gl__slice {
  position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none;
  color: $subject-ink;
  font-weight: 800; font-size: clamp(30px, 9vw, 48px); line-height: 1.15; white-space: nowrap;
}
.gl__slice--a {
  clip-path: inset(0 0 50% 0);              // 칩: clip-path — 위 반쪽
  animation-name: glSliceAOnce; animation-duration: $duration;
  animation-timing-function: $easing; animation-iteration-count: 1; animation-fill-mode: backwards;
}
.gl__slice--b {
  clip-path: inset(50% 0 0 0);              // 아래 반쪽
  animation-name: glSliceBOnce; animation-duration: $duration;
  animation-timing-function: $easing; animation-iteration-count: 1; animation-fill-mode: backwards;
}

진짜 제목은 아래에 그대로 두고 같은 글자를 위아래로 자른 겹 둘을 얹어 흔들기 때문에, 읽는 장치에는 멀쩡한 한 줄이 남고 눈에만 어긋남이 보입니다.

어긋남이 0으로 수렴하는 다섯 컷

@keyframes glSliceAOnce {
  0% { transform: translate(-9px, 0); text-shadow: 5px 0 $color; clip-path: inset(0 0 52% 0); }
  7% { transform: translate(5px, -1px); text-shadow: -4px 0 $subject-orange-deep; clip-path: inset(0 0 48% 0); }
  14% { transform: translate(-6px, 1px); text-shadow: 4px 0 $color; clip-path: inset(0 0 55% 0); }
  21% { transform: translate(3px, 0); text-shadow: -3px 0 $subject-orange-deep; clip-path: inset(0 0 47% 0); }
  28% { transform: translate(-2px, 0); text-shadow: 2px 0 $color; clip-path: inset(0 0 50% 0); }
  34%, 100% { transform: translate(0, 0); text-shadow: 0 0 transparent; clip-path: inset(0 0 50% 0); }
}

어긋남이 9px에서 5·6·3·2px로 줄고 자른 선도 52·48·55·47%로 흔들리다 34%에 전부 제자리로 앉는데, 움직이는 면이 글자뿐이라 누적면적은 9.744%로, 낱글자(03)·선(06)에 이어 작은 축입니다.

09컬러 플러드

색 띠 세 장이 차례로 화면을 훑고 지나가며 마지막 띠가 오른쪽으로 접혀 빠지면 그 아래 콘텐츠만 남는, 색이 곧 정체성인 브랜드 사이트용 인트로입니다.

컬러 플러드
새 창에서 열기

09 · 컬러 플러드 — 측정 누적면적 79.442% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scaleXtransform-originanimation-fill-mode

띠는 왼쪽에서 자라 오른쪽으로 접힌다

.cf__band {
  position: absolute; inset: 0; z-index: 2;
  transform-origin: left center;
  transform: translateX(0) scaleX(0);
  will-change: transform;
  animation-timing-function: linear;   // 파도 앞이 일정한 속도로 지나가게 — 자람·접힘 같은 곡선이어야 이음이 벌어지지 않는다
  animation-iteration-count: 1; animation-fill-mode: backwards;
}
@keyframes cfBand1 {
  0% { transform: translateX(0) scaleX(0); }
  13% { transform: translateX(0) scaleX(1); }
  26%, 100% { transform: translateX(100%) scaleX(0); }
}

transform-origin 을 왼쪽에 두면 자라는 방향은 얻지만 퇴장까지 왼쪽으로 접히므로, 이동과 축소를 같은 시간으로 함께 보간해 오른쪽 끝이 고정된 것처럼 빠지게 만들었습니다.

세 띠를 13%씩 줄 세우기

@keyframes cfBand2 {
  0%, 13% { transform: translateX(0) scaleX(0); }
  26% { transform: translateX(0) scaleX(1); }
  39%, 100% { transform: translateX(100%) scaleX(0); }
}
@keyframes cfBand3 {
  0%, 26% { transform: translateX(0) scaleX(0); }
  39% { transform: translateX(0) scaleX(1); }
  52%, 100% { transform: translateX(100%) scaleX(0); }
}
@keyframes cfContentOnce {
  0%, 38% { opacity: 0; transform: translateY(16px); }
  54%, 100% { opacity: 1; transform: translateY(0); }
}

앞 띠가 접히기 시작하는 지점에서 다음 띠가 자라기 시작하도록 13%씩 밀어 두어 띠 사이에 빈 화면이 끼지 않고, 마지막 띠가 빠지는 52% 직전에 콘텐츠가 솟아올라 누적면적 79.442%가 나옵니다.

인트로 애니메이션 만들기는 CSS 로 어디까지 되나?

아홉 개 모두 연출은 CSS 키프레임이 맡고 자바스크립트는 클래스 두 개를 갈아 끼우는 일만 합니다. 스크롤 위치에 묶인 긴 시퀀스, 입자가 흩날리는 캔버스, 영상과 자막을 맞추는 타임라인처럼 재생 위치를 코드로 되감아야 하는 연출은 전용 도구가 맞습니다. 반대로 덮개 한 장·글자 한 줄·색 띠 몇 장으로 끝나는 인트로 만들기라면 추가로 받아야 할 파일이 없고, 기다리는 화면이 필요한 쪽은 CSS 로딩 스피너 9종이 더 가볍습니다.

어디서 깨지는가 — 함정 셋

인트로는 화면 전체를 덮는 요소를 다루기 때문에, 평소 카드나 버튼에서 안 나오던 사고가 세 가지 방향으로 납니다.

전면 요소에 scale 을 걸면 반응형 검사가 깨진다

// 세로 묶음 — dolly-out 은 화면보다 작은 이 상자에 건다(전면 요소를 scale up 하면 scrollWidth 가 넘친다)
.iw__box {
  display: flex; flex-direction: column; align-items: center;
  width: min(84%, 400px); gap: $sp-2; text-align: center;
}
@keyframes iwSettle {
  0% { transform: scale(1.045); }
  78% { transform: scale(1); }
  100% { transform: scale(1); }
}

04 의 첫 판은 화면을 꽉 채운 inset: 0 요소에 scale(1.045) 를 걸었습니다. overflow: hidden 을 걸어도 scrollWidth 는 변환된 경계 상자를 기준으로 계산되므로 확대한 비율만큼 가로가 넘쳤고, 반응형 검사가 좁은 폭에서 넘침으로 잡았습니다. 확대는 화면보다 작은 안쪽 상자에 옮겨 걸었고, 05 의 떠오름도 같은 이유로 .cd__box 안쪽에만 걸어 두 항목 모두 넘침 판정이 false로 남았습니다.

복귀가 등장보다 빠르면 대표 이미지가 빈 화면이 된다

@keyframes clTopLoop {
  0%, 4% { transform: translateY(0); }
  26%, 66% { transform: translateY(-102%); animation-timing-function: linear; }
  88%, 100% { transform: translateY(0); }
}
@keyframes scrPanelLLoop {
  0%, 6% { transform: translateX(0); }
  45% { transform: translateX(-102%); }
  70% { transform: translateX(-102%); animation-timing-function: linear; }
  90%, 100% { transform: translateX(0); }
}

격자에 걸리는 대표 이미지는 24장을 이웃끼리 견준 23칸 가운데 바뀐 면적이 가장 큰 순간으로 뽑힙니다. 인트로 루프는 열린 뒤 다시 닫아야 하는데 복귀를 짧게 끊으면 그 한 칸이 최대 변화가 되어, 대표 이미지가 가림막에 덮인 빈 화면으로 찍힙니다. 그래서 01 과 07 은 여는 쪽에 스프링을 두고 되닫는 구간만 linear 로 길게 늘였고, 사라짐도 투명도 대신 덮개가 다시 덮는 방식으로 처리했습니다. 아홉 개를 한 번에 받는 압축 파일의 비밀번호는 g2nru27q 이며, 02 처럼 조각이 흩어지는 항목도 같은 이유로 흩어짐 구간을 컷이 아닌 선형으로 두었습니다.

컷은 구간 끝에서만 값을 바꾼다

// steps(1, end) 는 구간 끝에서만 값이 바뀐다 — 컷 지점을 중간 키프레임(14·28·42%)으로 직접 찍는다
.cd.is-demo .cd__site {
  animation-name: cdSite;
  animation-duration: $duration;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
  animation-fill-mode: both;
}
@keyframes cdSite { 0% { opacity: 0; } 42% { opacity: 1; } 100% { opacity: 1; } }

steps(1, end) 는 한 구간을 한 칸으로 보고 그 끝에서 값을 바꾸므로, 0%와 100%에만 값을 두면 갈아 끼우는 순간이 재생이 끝나는 시점에 몰려 화면에서는 아무 일도 일어나지 않습니다. 05 의 카운트다운은 숫자가 바뀌어야 할 14·28·42% 를 중간 키프레임으로 직접 찍어 세 번의 컷을 만들었고, 06 의 글자 켜짐도 0%와 2% 사이에 컷 지점을 둬 같은 함정을 피했습니다.

변형 세 가지와 접근성

토큰 세 개만 바꾸면 아홉 개의 성격이 함께 바뀝니다.

값 세 개로 아홉 개를 함께 돌리기

이름 바꾼 값 느낌
duration $dur-enter 600ms → 300ms 가림막과 글자가 두 배로 빨리 걷힌다
easing $ease-spring → $ease-pop 제목이 자리를 잡을 때 한 번 더 튄다
color $subject-blue → $stage-orange 덮개 색이 차가운 쪽에서 따뜻한 쪽으로

움직임을 줄였을 때 남는 한 장

아홉 개는 모두 기본 상태가 완료 화면이라 prefers-reduced-motion 에서 애니메이션만 끄면 인트로가 끝난 화면이 그대로 남습니다. 01·02·03 은 animation: none !important, 07·08·09 는 animation-name: none !important 로 미리보기 규칙의 우선순위를 넘어섭니다. 되감아야 할 상태가 없으니 움직임을 끈 사람은 덮개도 카운트다운도 보지 않고 바로 본문을 만납니다.

눈과 귀에 같은 것이 남게 하기

가림막·조각·색 띠처럼 장식인 요소에는 aria-hidden="true" 를 붙이고, 건너뛰었다는 사실은 aria-live="polite" 한 줄이 알립니다. 글자 대비는 아홉 화면의 글자를 토큰 값에서 직접 계산해 AA 미달 0건을 확인했는데, 01 의 제목은 잉크 배경 #17141a 위 17.11:1, 03 의 낱글자는 주황 #ff4d1f 위 5.50:1, 09 의 상단 라벨은 잉크 위 5.69:1 입니다. 기법의 근거는 clip-path·stroke-dasharray·sessionStorage 문서에 있고, 화면이 바뀌는 동안의 처리를 더 보려면 페이지 로딩 애니메이션 9종이 이어집니다.

FAQ

새로고침할 때마다 인트로를 또 보여줘도 되나요

아홉 개 모두 sessionStorage 에 표식을 하나 남겨 같은 탭에서 다시 들어오면 인트로를 건너뜁니다. 탭을 닫으면 표식도 사라지므로 처음 온 사람에게는 한 번 보이고, 돌아온 사람은 기다리지 않습니다. 저장이 막힌 브라우저에서도 예외를 삼키고 넘어가므로 화면이 멈추지 않습니다.

오프닝 애니메이션을 넣으면 첫 화면이 느려 보이지 않나요

아홉 개는 이미지나 외부 라이브러리를 더 받지 않고 CSS 키프레임과 열 줄 남짓의 스크립트만 씁니다. 콘텐츠는 덮개 아래에 이미 불투명하게 깔려 있어 가려져 있을 뿐 나중에 만들어지지 않습니다. 그래도 짧게 가고 싶으면 재생 시간을 $dur-base 300ms로 낮추는 변형이 가장 손이 적게 갑니다.

화면 낭독기를 쓰는 사람에게는 인트로가 어떻게 들리나요

덮개와 조각은 전부 aria-hidden 이라 읽히지 않고, 03 처럼 글자를 쪼갠 항목은 줄 전체에 aria-label 을 붙여 낱글자가 아니라 한 낱말로 들립니다. 건너뛰기 버튼은 처음부터 탭 순서 안에 있고 Esc도 같은 함수를 부르므로, 마우스 없이도 첫 키 입력에 본문으로 갈 수 있습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.